From 3689b6113f802021e837ccf251694479f3300013 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 14:05:40 +0000 Subject: [PATCH 1/7] feat(db): make db studio a polished read-only explorer --- .changeset/studio-read-only.md | 9 + .github/workflows/ci.yml | 2 + AGENTS.md | 1 + bun.lock | 68 +- packages/cli/README.md | 4 +- packages/cli/src/commands/db/api.ts | 2 +- packages/cli/src/commands/db/credentials.ts | 10 +- packages/cli/src/commands/db/studio.ts | 13 +- .../database-adapter/src/introspect.test.ts | 13 + packages/database-adapter/src/introspect.ts | 16 +- packages/database-openapi/src/generate.ts | 7 +- packages/database-rest/README.md | 8 + packages/database-rest/src/handler.test.ts | 106 +++ packages/database-rest/src/handler.ts | 34 +- packages/database-rest/src/index.ts | 4 + packages/database-rest/src/parser.ts | 107 ++- packages/database-rest/src/sql.ts | 140 +-- packages/database-studio/client/src/App.tsx | 258 ++---- packages/database-studio/client/src/api.ts | 314 ++----- .../client/src/components/CellValue.tsx | 42 + .../src/components/ColumnToggleMenu.tsx | 62 -- .../client/src/components/DataGrid.tsx | 234 +++++ .../client/src/components/DataTab.tsx | 287 ------- .../client/src/components/DatabaseIcon.tsx | 18 + .../client/src/components/FadeScrollArea.tsx | 34 - .../client/src/components/FilterBar.tsx | 385 ++++----- .../client/src/components/Overview.tsx | 77 ++ .../client/src/components/Popover.tsx | 78 ++ .../client/src/components/RowSheet.tsx | 304 +++++++ .../client/src/components/SchemaTab.tsx | 320 ++++--- .../client/src/components/Sidebar.tsx | 159 ++++ .../client/src/components/TableView.tsx | 806 +++++++++++------- .../client/src/components/ui/badge.tsx | 37 - .../client/src/components/ui/button-group.tsx | 38 - .../client/src/components/ui/data-table.tsx | 238 ------ .../client/src/components/ui/input.tsx | 21 - .../client/src/components/ui/scroll-area.tsx | 45 - .../client/src/components/ui/table.tsx | 117 --- .../client/src/hooks/use-url-state.ts | 33 +- .../database-studio/client/src/lib/filters.ts | 92 ++ .../database-studio/client/src/lib/format.ts | 55 ++ packages/database-studio/client/src/main.tsx | 16 +- packages/database-studio/client/tsconfig.json | 4 +- .../database-studio/client/vite.config.ts | 1 + packages/database-studio/package.json | 5 +- packages/database-studio/src/server.ts | 64 +- 46 files changed, 2542 insertions(+), 2146 deletions(-) create mode 100644 .changeset/studio-read-only.md create mode 100644 packages/database-studio/client/src/components/CellValue.tsx delete mode 100644 packages/database-studio/client/src/components/ColumnToggleMenu.tsx create mode 100644 packages/database-studio/client/src/components/DataGrid.tsx delete mode 100644 packages/database-studio/client/src/components/DataTab.tsx create mode 100644 packages/database-studio/client/src/components/DatabaseIcon.tsx delete mode 100644 packages/database-studio/client/src/components/FadeScrollArea.tsx create mode 100644 packages/database-studio/client/src/components/Overview.tsx create mode 100644 packages/database-studio/client/src/components/Popover.tsx create mode 100644 packages/database-studio/client/src/components/RowSheet.tsx create mode 100644 packages/database-studio/client/src/components/Sidebar.tsx delete mode 100644 packages/database-studio/client/src/components/ui/badge.tsx delete mode 100644 packages/database-studio/client/src/components/ui/button-group.tsx delete mode 100644 packages/database-studio/client/src/components/ui/data-table.tsx delete mode 100644 packages/database-studio/client/src/components/ui/input.tsx delete mode 100644 packages/database-studio/client/src/components/ui/scroll-area.tsx delete mode 100644 packages/database-studio/client/src/components/ui/table.tsx create mode 100644 packages/database-studio/client/src/lib/filters.ts create mode 100644 packages/database-studio/client/src/lib/format.ts diff --git a/.changeset/studio-read-only.md b/.changeset/studio-read-only.md new file mode 100644 index 00000000..548318e0 --- /dev/null +++ b/.changeset/studio-read-only.md @@ -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. The UI gets a searchable table sidebar, 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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a56b70b7..bd69f5d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 3488d94d..3c6575f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -518,6 +518,7 @@ 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 two layers.** `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. Adding writes means lifting both deliberately, not just one. - **Every CLI connection identifies itself** via `databaseUserAgent(command)` (`bunny-cli/ (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/`. - **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. diff --git a/bun.lock b/bun.lock index 04e93593..1e17b3d1 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "packages/cli": { "name": "@bunny.net/cli", - "version": "0.18.0", + "version": "0.19.0", "bin": { "bunny": "./bin/bunny.cjs", }, @@ -44,36 +44,36 @@ "zod": "^4.3.6", }, "optionalDependencies": { - "@bunny.net/cli-darwin-arm64": "0.18.0", - "@bunny.net/cli-darwin-x64": "0.18.0", - "@bunny.net/cli-linux-arm64": "0.18.0", - "@bunny.net/cli-linux-x64": "0.18.0", - "@bunny.net/cli-windows-x64": "0.18.0", + "@bunny.net/cli-darwin-arm64": "0.19.0", + "@bunny.net/cli-darwin-x64": "0.19.0", + "@bunny.net/cli-linux-arm64": "0.19.0", + "@bunny.net/cli-linux-x64": "0.19.0", + "@bunny.net/cli-windows-x64": "0.19.0", }, }, "packages/cli-darwin-arm64": { "name": "@bunny.net/cli-darwin-arm64", - "version": "0.18.0", + "version": "0.19.0", }, "packages/cli-darwin-x64": { "name": "@bunny.net/cli-darwin-x64", - "version": "0.18.0", + "version": "0.19.0", }, "packages/cli-linux-arm64": { "name": "@bunny.net/cli-linux-arm64", - "version": "0.18.0", + "version": "0.19.0", }, "packages/cli-linux-x64": { "name": "@bunny.net/cli-linux-x64", - "version": "0.18.0", + "version": "0.19.0", }, "packages/cli-windows-x64": { "name": "@bunny.net/cli-windows-x64", - "version": "0.18.0", + "version": "0.19.0", }, "packages/config": { "name": "@bunny.net/config", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "zod": "^4.3.6", @@ -98,7 +98,7 @@ "packages/database-client/examples/bun": { "name": "database-client-example-bun", "dependencies": { - "@bunny.net/database-client": "^0.0.1", + "@bunny.net/database-client": "*", }, "devDependencies": { "@types/bun": "latest", @@ -110,7 +110,7 @@ "packages/database-client/examples/hono": { "name": "database-client-example-hono", "dependencies": { - "@bunny.net/database-client": "^0.0.1", + "@bunny.net/database-client": "*", "hono": "^4.13.3", }, "devDependencies": { @@ -191,11 +191,9 @@ "devDependencies": { "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.0.0", - "@tanstack/react-table": "^8.21.3", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", @@ -213,14 +211,14 @@ }, "packages/framework-detector": { "name": "@bunny.net/framework-detector", - "version": "0.0.0", + "version": "0.1.0", "devDependencies": { "typescript": "^5", }, }, "packages/openapi-client": { "name": "@bunny.net/openapi-client", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "openapi-fetch": "^0.17.0", }, @@ -231,7 +229,7 @@ }, "packages/sandbox": { "name": "@bunny.net/sandbox", - "version": "0.4.1", + "version": "0.4.2", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "@types/ssh2": "^1.15.0", @@ -247,7 +245,7 @@ }, "packages/stream-import": { "name": "@bunny.net/stream-import", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "openapi-fetch": "^0.17.0", @@ -259,7 +257,7 @@ }, "packages/stream-import-brightcove": { "name": "@bunny.net/stream-import-brightcove", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@bunny.net/stream-import": "workspace:^", "zod": "^4.3.6", @@ -270,7 +268,7 @@ }, "packages/stream-import-cloudflare": { "name": "@bunny.net/stream-import-cloudflare", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@bunny.net/stream-import": "workspace:^", "zod": "^4.3.6", @@ -281,7 +279,7 @@ }, "packages/stream-import-jwplayer": { "name": "@bunny.net/stream-import-jwplayer", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@bunny.net/stream-import": "workspace:^", "zod": "^4.3.6", @@ -292,7 +290,7 @@ }, "packages/stream-import-mux": { "name": "@bunny.net/stream-import-mux", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@bunny.net/stream-import": "workspace:^", "zod": "^4.3.6", @@ -303,7 +301,7 @@ }, "packages/stream-import-s3": { "name": "@bunny.net/stream-import-s3", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", @@ -316,7 +314,7 @@ }, "packages/stream-import-vimeo": { "name": "@bunny.net/stream-import-vimeo", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@bunny.net/stream-import": "workspace:^", "zod": "^4.3.6", @@ -327,7 +325,7 @@ }, "packages/stream-import-wistia": { "name": "@bunny.net/stream-import-wistia", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@bunny.net/stream-import": "workspace:^", "zod": "^4.3.6", @@ -338,7 +336,7 @@ }, "packages/tools": { "name": "@bunny.net/tools", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "@bunny.net/stream-import": "workspace:*", @@ -673,8 +671,6 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], - "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -801,10 +797,6 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], - "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], - - "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1303,9 +1295,9 @@ "cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - "database-client-example-bun/@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "database-client-example-bun/@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], - "database-client-example-hono/@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "database-client-example-hono/@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1327,9 +1319,9 @@ "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "database-client-example-bun/@types/bun/bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "database-client-example-bun/@types/bun/bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], - "database-client-example-hono/@types/bun/bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "database-client-example-hono/@types/bun/bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "ora/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], diff --git a/packages/cli/README.md b/packages/cli/README.md index 0f665dbf..91c6a90a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -373,7 +373,9 @@ 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. + +Browse tables from the sidebar (⌘K / Ctrl K to search), sort by any column, and 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, and incoming foreign keys. Export the current page as CSV or JSON. 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) diff --git a/packages/cli/src/commands/db/api.ts b/packages/cli/src/commands/db/api.ts index 9f6b32b1..8ee47c07 100644 --- a/packages/cli/src/commands/db/api.ts +++ b/packages/cli/src/commands/db/api.ts @@ -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"]; diff --git a/packages/cli/src/commands/db/credentials.ts b/packages/cli/src/commands/db/credentials.ts index b327d86f..75ec6e96 100644 --- a/packages/cli/src/commands/db/credentials.ts +++ b/packages/cli/src/commands/db/credentials.ts @@ -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"; @@ -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`. */ @@ -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(), }); }; diff --git a/packages/cli/src/commands/db/studio.ts b/packages/cli/src/commands/db/studio.ts index c7a760b5..f8c12f90 100644 --- a/packages/cli/src/commands/db/studio.ts +++ b/packages/cli/src/commands/db/studio.ts @@ -6,10 +6,10 @@ import { 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"; @@ -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),", @@ -107,13 +108,14 @@ 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({ + const { url, token, databaseId } = await resolveCredentials({ url: urlArg, token: tokenArg, databaseId: databaseIdArg, profile, apiKey, verbose, + authorization: "read-only", }); const client = connect({ @@ -125,6 +127,7 @@ export const dbStudioCommand = defineCommand<{ logger.log(""); await startStudio({ client, + label: databaseTarget(url, databaseId).label, port: port ?? 4488, open: !noOpen, dev, diff --git a/packages/database-adapter/src/introspect.test.ts b/packages/database-adapter/src/introspect.test.ts index 0c32ed9d..47ddc4db 100644 --- a/packages/database-adapter/src/introspect.test.ts +++ b/packages/database-adapter/src/introspect.test.ts @@ -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 @@ -121,6 +126,14 @@ 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("uses custom version", async () => { const schema = await introspect({ client, version: "2.0.0" }); expect(schema.version).toBe("2.0.0"); diff --git a/packages/database-adapter/src/introspect.ts b/packages/database-adapter/src/introspect.ts index bcfd63bc..f6c3d04b 100644 --- a/packages/database-adapter/src/introspect.ts +++ b/packages/database-adapter/src/introspect.ts @@ -50,22 +50,29 @@ const getTables = async (client: AdapterClient): Promise => { 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 => { +): 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 ( @@ -111,10 +118,9 @@ const introspectTable = async ( client: AdapterClient, tableName: string, ): Promise => { - 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); diff --git a/packages/database-openapi/src/generate.ts b/packages/database-openapi/src/generate.ts index 7c81f1b3..2e2da64c 100644 --- a/packages/database-openapi/src/generate.ts +++ b/packages/database-openapi/src/generate.ts @@ -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); diff --git a/packages/database-rest/README.md b/packages/database-rest/README.md index 8527b025..61430e2e 100644 --- a/packages/database-rest/README.md +++ b/packages/database-rest/README.md @@ -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 malformed `or` group returns `400` rather than being dropped, since ignoring part of it would match more rows. + #### Order ``` @@ -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 diff --git a/packages/database-rest/src/handler.test.ts b/packages/database-rest/src/handler.test.ts index 3a81ccb5..bcbb21c5 100644 --- a/packages/database-rest/src/handler.test.ts +++ b/packages/database-rest/src/handler.test.ts @@ -193,6 +193,73 @@ describe("GET /:table", () => { expect(body.data).toHaveLength(2); }); + test("filter with not.is.null", async () => { + const res = await handler(req("GET", "/users?age=not.is.null")); + const body = await jsonBody(res); + + expect(body.data.map((u: { name: string }) => u.name).sort()).toEqual([ + "Alice", + "Bob", + ]); + }); + + test("filter with not.like", async () => { + const res = await handler(req("GET", "/users?name=not.like.A%25")); + const body = await jsonBody(res); + + expect(body.data).toHaveLength(2); + expect(res.headers.get("X-Total-Count")).toBe("2"); + }); + + test("or group matches either condition and counts the same rows", async () => { + const res = await handler( + req( + "GET", + `/users?or=${encodeURIComponent("(name.eq.Alice,age.is.null)")}`, + ), + ); + const body = await jsonBody(res); + + expect(body.data.map((u: { name: string }) => u.name).sort()).toEqual([ + "Alice", + "Charlie", + ]); + expect(res.headers.get("X-Total-Count")).toBe("2"); + }); + + test("or group values can be quoted to carry commas", async () => { + const res = await handler( + req( + "GET", + `/users?or=${encodeURIComponent('(name.eq."Bob, Jr",name.eq.Bob)')}`, + ), + ); + const body = await jsonBody(res); + + expect(body.data).toHaveLength(1); + expect(body.data[0].name).toBe("Bob"); + }); + + test("or group values can escape quotes", async () => { + const res = await handler( + req( + "GET", + `/users?or=${encodeURIComponent('(name.eq."say \\"hi\\"",name.eq.Alice)')}`, + ), + ); + const body = await jsonBody(res); + + expect(body.data).toHaveLength(1); + expect(body.data[0].name).toBe("Alice"); + }); + + test("malformed or group returns 400 instead of widening the match", async () => { + const res = await handler( + req("GET", `/users?or=${encodeURIComponent("(name.eq.Alice,bogus)")}`), + ); + expect(res.status).toBe(400); + }); + test("order by column desc", async () => { const res = await handler(req("GET", "/users?order=name.desc")); const body = await jsonBody(res); @@ -221,6 +288,45 @@ describe("GET /:table", () => { }); }); +describe("value encoding", () => { + test("sends bigint as a decimal string and bytes as base64", async () => { + const stub: DatabaseExecutor = { + execute: async () => ({ + columns: ["id", "data"], + rows: [ + { + id: 9007199254740993n, + data: new Uint8Array([1, 2, 255]), + count: 1, + }, + ], + }), + }; + const res = await createRestHandler(stub, schema)(req("GET", "/users")); + expect(res.status).toBe(200); + + const body = await jsonBody(res); + expect(body.data[0].id).toBe("9007199254740993"); + expect(body.data[0].data).toBe("AQL/"); + }); + + test("filters on integers beyond 2^53 without rounding them", async () => { + const big = new Database(":memory:"); + big.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"); + big.exec( + "INSERT INTO users VALUES (9007199254740992, 'rounded'), (9007199254740993, 'exact')", + ); + const res = await createRestHandler( + createTestExecutor(big), + schema, + )(req("GET", "/users?select=name&id=eq.9007199254740993")); + const body = await jsonBody(res); + big.close(); + + expect(body.data).toEqual([{ name: "exact" }]); + }); +}); + describe("POST /:table", () => { test("inserts a single row", async () => { const res = await handler( diff --git a/packages/database-rest/src/handler.ts b/packages/database-rest/src/handler.ts index 012daf1d..d40b2363 100644 --- a/packages/database-rest/src/handler.ts +++ b/packages/database-rest/src/handler.ts @@ -4,7 +4,7 @@ import type { } from "@bunny.net/database-openapi"; import { generateOpenAPISpec } from "@bunny.net/database-openapi"; import type { DatabaseExecutor } from "./executor.ts"; -import { parseQueryParams } from "./parser.ts"; +import { parseQueryParams, parseValue, QueryParseError } from "./parser.ts"; import { buildCountQuery, buildDeleteQuery, @@ -22,8 +22,19 @@ export interface RestHandlerOptions { onError?: (err: unknown) => void; } +/** + * JSON has no 64-bit integer or binary type: integers beyond 2^53 arrive from + * the client as bigint (which JSON.stringify throws on) and BLOBs as + * Uint8Array. Send them as a decimal string and a base64 string. + */ +const encodeValue = (_key: string, value: unknown): unknown => { + if (typeof value === "bigint") return value.toString(); + if (value instanceof Uint8Array) return Buffer.from(value).toString("base64"); + return value; +}; + const json = (data: unknown, status = 200, headers?: Record) => - new Response(JSON.stringify(data), { + new Response(JSON.stringify(data, encodeValue), { status, headers: { "Content-Type": "application/json", ...headers }, }); @@ -72,14 +83,6 @@ const parseRoute = ( return null; }; -const parsePathValue = (raw: string): string | number => { - const num = Number(raw); - if (!Number.isNaN(num) && raw.trim() !== "") { - return num; - } - return raw; -}; - export const createRestHandler = ( executor: DatabaseExecutor, schema: DatabaseSchema, @@ -188,6 +191,9 @@ export const createRestHandler = ( return errorResponse("Method not allowed", 405, "METHOD_NOT_ALLOWED"); } } catch (err: unknown) { + if (err instanceof QueryParseError) { + return errorResponse(err.message, 400, "BAD_REQUEST"); + } // The detail goes to the host's logger, not the response: a mounted handler must not leak schema names or file paths. try { options.onError?.(err); @@ -333,9 +339,7 @@ const handleGetOne = async ( const query = parseQueryParams(url); const selectQuery = buildSelectQuery(table, { select: query.select, - filters: [ - { column: pkColumn, operator: "eq", value: parsePathValue(pkValue) }, - ], + filters: [{ column: pkColumn, operator: "eq", value: parseValue(pkValue) }], order: [], limit: 1, }); @@ -371,7 +375,7 @@ const handlePatchOne = async ( } const updateQuery = buildUpdateQuery(table, body as Record, [ - { column: pkColumn, operator: "eq", value: parsePathValue(pkValue) }, + { column: pkColumn, operator: "eq", value: parseValue(pkValue) }, ]); const result = await executor.execute(updateQuery.sql, updateQuery.args); @@ -389,7 +393,7 @@ const handleDeleteOne = async ( pkValue: string, ): Promise => { const deleteQuery = buildDeleteQuery(table, [ - { column: pkColumn, operator: "eq", value: parsePathValue(pkValue) }, + { column: pkColumn, operator: "eq", value: parseValue(pkValue) }, ]); const result = await executor.execute(deleteQuery.sql, deleteQuery.args); diff --git a/packages/database-rest/src/index.ts b/packages/database-rest/src/index.ts index da5817cf..75491eaf 100644 --- a/packages/database-rest/src/index.ts +++ b/packages/database-rest/src/index.ts @@ -4,7 +4,9 @@ export type { DatabaseExecutor, ExecuteResult } from "./executor.ts"; export type { RestHandlerOptions } from "./handler.ts"; export { createRestHandler } from "./handler.ts"; export type { + Filter, FilterCondition, + FilterGroup, FilterOperator, OrderClause, ParsedQuery, @@ -13,8 +15,10 @@ export type { export { parseFilterValue, parseOrder, + parseOrGroup, parseQueryParams, parseSelect, + QueryParseError, } from "./parser.ts"; export { buildCountQuery, diff --git a/packages/database-rest/src/parser.ts b/packages/database-rest/src/parser.ts index 7228116d..2549308d 100644 --- a/packages/database-rest/src/parser.ts +++ b/packages/database-rest/src/parser.ts @@ -16,8 +16,17 @@ export interface FilterCondition { column: string; operator: FilterOperator; value: string | number | boolean | null | (string | number)[]; + /** Set by the `not.` prefix (`?name=not.like.a%25`); the condition is wrapped in `NOT (...)`. */ + negate?: boolean; } +/** One `or=(...)` parameter: matches rows where any condition holds. */ +export interface FilterGroup { + or: FilterCondition[]; +} + +export type Filter = FilterCondition | FilterGroup; + export interface OrderClause { column: string; direction: SortDirection; @@ -26,12 +35,15 @@ export interface OrderClause { export interface ParsedQuery { select: string[]; - filters: FilterCondition[]; + filters: Filter[]; order: OrderClause[]; limit?: number; offset?: number; } +/** Thrown for query parameters that can't be applied safely; the handler answers 400. */ +export class QueryParseError extends Error {} + const FILTER_OPERATORS: Record = { eq: "eq", neq: "neq", @@ -45,7 +57,7 @@ const FILTER_OPERATORS: Record = { in: "in", }; -const RESERVED_PARAMS = new Set(["select", "order", "limit", "offset"]); +const RESERVED_PARAMS = new Set(["select", "order", "limit", "offset", "or"]); export function parseSelect(selectParam: string | null): string[] { if (!selectParam || selectParam === "*") { @@ -73,6 +85,12 @@ export function parseFilterValue( const operatorStr = value.substring(0, dotIndex); const rawValue = value.substring(dotIndex + 1); + if (operatorStr === "not") { + const inner = parseFilterValue(column, rawValue); + if (!inner || inner.negate || !rawValue.includes(".")) return null; + return { ...inner, negate: true }; + } + const operator = FILTER_OPERATORS[operatorStr]; if (!operator) { return null; @@ -102,11 +120,7 @@ function parseInValues(raw: string): (string | number)[] { return [raw]; } - return inner.split(",").map((v) => { - const trimmed = v.trim(); - const num = Number(trimmed); - return Number.isNaN(num) ? trimmed : num; - }); + return inner.split(",").map((v) => parseValue(v.trim())); } function parseIsValue(raw: string): boolean | null { @@ -117,12 +131,73 @@ function parseIsValue(raw: string): boolean | null { return null; } -function parseValue(raw: string): string | number { +/** + * Numeric strings become numbers, except integers beyond 2^53, which a + * number can't hold exactly; those stay strings, and SQLite's INTEGER + * affinity still compares them as integers. + */ +export function parseValue(raw: string): string | number { const num = Number(raw); - if (!Number.isNaN(num) && raw.trim() !== "") { - return num; + if (Number.isNaN(num) || raw.trim() === "") return raw; + if (Number.isInteger(num) && !Number.isSafeInteger(num)) return raw; + return num; +} + +/** + * Split on commas that sit outside parentheses and double quotes, so + * `a.in.(1,2),b.eq."x,y"` yields two parts. Inside quotes, a backslash escapes + * the next character. + */ +function splitTopLevel(input: string): string[] { + const parts: string[] = []; + let depth = 0; + let quoted = false; + let escaped = false; + let current = ""; + for (const ch of input) { + if (escaped) escaped = false; + else if (quoted && ch === "\\") escaped = true; + else if (ch === '"') quoted = !quoted; + else if (!quoted && ch === "(") depth++; + else if (!quoted && ch === ")") depth--; + if (ch === "," && depth === 0 && !quoted) { + parts.push(current); + current = ""; + } else { + current += ch; + } + } + parts.push(current); + return parts; +} + +/** + * Parse an `or=(col.op.value,...)` parameter. Values may be wrapped in double + * quotes to carry commas or parentheses; inside quotes, a backslash escapes the + * next character. Returns null when any condition is malformed: dropping one + * branch of an OR would widen the match. + */ +export function parseOrGroup(raw: string): FilterGroup | null { + const match = raw.match(/^\((.*)\)$/s); + if (!match?.[1]) return null; + + const conditions: FilterCondition[] = []; + for (const part of splitTopLevel(match[1])) { + const dotIndex = part.indexOf("."); + if (dotIndex <= 0) return null; + const column = part.substring(0, dotIndex).trim(); + let rest = part.substring(dotIndex + 1); + // Unquote `op."value"` (and `not.op."value"`) so quoted values compare as written. + rest = rest.replace( + /^((?:not\.)?[a-z]+\.)"(.*)"$/s, + (_, prefix: string, value: string) => + prefix + value.replace(/\\(.)/gs, "$1"), + ); + const condition = parseFilterValue(column, rest); + if (!condition || !rest.includes(".")) return null; + conditions.push(condition); } - return raw; + return { or: conditions }; } export function parseOrder(orderParam: string | null): OrderClause[] { @@ -157,7 +232,15 @@ export function parseQueryParams(url: URL): ParsedQuery { const offsetParam = params.get("offset"); const offset = offsetParam ? parseInt(offsetParam, 10) : undefined; - const filters: FilterCondition[] = []; + const filters: Filter[] = []; + + for (const orParam of params.getAll("or")) { + const group = parseOrGroup(orParam); + if (!group) { + throw new QueryParseError(`Invalid or filter: ${orParam}`); + } + filters.push(group); + } for (const [key, value] of params.entries()) { if (RESERVED_PARAMS.has(key)) { diff --git a/packages/database-rest/src/sql.ts b/packages/database-rest/src/sql.ts index 712bf3ef..9dbe508d 100644 --- a/packages/database-rest/src/sql.ts +++ b/packages/database-rest/src/sql.ts @@ -1,4 +1,9 @@ -import type { FilterCondition, OrderClause, ParsedQuery } from "./parser.ts"; +import type { + Filter, + FilterCondition, + OrderClause, + ParsedQuery, +} from "./parser.ts"; function quoteIdentifier(name: string): string { return `"${name.replace(/"/g, '""')}"`; @@ -6,72 +11,81 @@ function quoteIdentifier(name: string): string { interface WhereClause { sql: string; - args: (string | number | boolean | null)[]; + args: SqlArg[]; } -function buildWhere(filters: FilterCondition[]): WhereClause { +type SqlArg = string | number | boolean | null; + +function buildCondition(filter: FilterCondition, args: SqlArg[]): string { + const col = quoteIdentifier(filter.column); + let sql: string; + + switch (filter.operator) { + case "eq": + sql = `${col} = ?`; + args.push(filter.value as string | number); + break; + case "neq": + sql = `${col} != ?`; + args.push(filter.value as string | number); + break; + case "gt": + sql = `${col} > ?`; + args.push(filter.value as string | number); + break; + case "gte": + sql = `${col} >= ?`; + args.push(filter.value as string | number); + break; + case "lt": + sql = `${col} < ?`; + args.push(filter.value as string | number); + break; + case "lte": + sql = `${col} <= ?`; + args.push(filter.value as string | number); + break; + case "like": + sql = `${col} LIKE ?`; + args.push(filter.value as string); + break; + case "ilike": + sql = `${col} LIKE ? COLLATE NOCASE`; + args.push(filter.value as string); + break; + case "is": { + if (filter.value === null) { + sql = `${col} IS NULL`; + } else if (filter.value === true) { + sql = `${col} IS TRUE`; + } else { + sql = `${col} IS FALSE`; + } + break; + } + case "in": { + const values = filter.value as (string | number)[]; + const placeholders = values.map(() => "?").join(", "); + sql = `${col} IN (${placeholders})`; + args.push(...values); + break; + } + } + + return filter.negate ? `NOT (${sql})` : sql; +} + +function buildWhere(filters: Filter[]): WhereClause { if (filters.length === 0) { return { sql: "", args: [] }; } - const conditions: string[] = []; - const args: (string | number | boolean | null)[] = []; - - for (const filter of filters) { - const col = quoteIdentifier(filter.column); - - switch (filter.operator) { - case "eq": - conditions.push(`${col} = ?`); - args.push(filter.value as string | number); - break; - case "neq": - conditions.push(`${col} != ?`); - args.push(filter.value as string | number); - break; - case "gt": - conditions.push(`${col} > ?`); - args.push(filter.value as string | number); - break; - case "gte": - conditions.push(`${col} >= ?`); - args.push(filter.value as string | number); - break; - case "lt": - conditions.push(`${col} < ?`); - args.push(filter.value as string | number); - break; - case "lte": - conditions.push(`${col} <= ?`); - args.push(filter.value as string | number); - break; - case "like": - conditions.push(`${col} LIKE ?`); - args.push(filter.value as string); - break; - case "ilike": - conditions.push(`${col} LIKE ? COLLATE NOCASE`); - args.push(filter.value as string); - break; - case "is": { - if (filter.value === null) { - conditions.push(`${col} IS NULL`); - } else if (filter.value === true) { - conditions.push(`${col} IS TRUE`); - } else { - conditions.push(`${col} IS FALSE`); - } - break; - } - case "in": { - const values = filter.value as (string | number)[]; - const placeholders = values.map(() => "?").join(", "); - conditions.push(`${col} IN (${placeholders})`); - args.push(...values); - break; - } - } - } + const args: SqlArg[] = []; + const conditions = filters.map((filter) => + "or" in filter + ? `(${filter.or.map((f) => buildCondition(f, args)).join(" OR ")})` + : buildCondition(filter, args), + ); return { sql: ` WHERE ${conditions.join(" AND ")}`, @@ -153,7 +167,7 @@ export function buildInsertQuery( export function buildUpdateQuery( table: string, values: Record, - filters: FilterCondition[], + filters: Filter[], ): SelectQuery { const setClauses = Object.keys(values).map( (col) => `${quoteIdentifier(col)} = ?`, @@ -169,7 +183,7 @@ export function buildUpdateQuery( export function buildDeleteQuery( table: string, - filters: FilterCondition[], + filters: Filter[], ): SelectQuery { const where = buildWhere(filters); diff --git a/packages/database-studio/client/src/App.tsx b/packages/database-studio/client/src/App.tsx index 76d5f0f9..30d182b6 100644 --- a/packages/database-studio/client/src/App.tsx +++ b/packages/database-studio/client/src/App.tsx @@ -1,221 +1,75 @@ -import { Plus, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { fetchTables, type TableSummary } from "@/api.ts"; -import { LoadingScreen } from "@/components/LoadingScreen.tsx"; -import { TableView } from "@/components/TableView.tsx"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { setUrlParams, useUrlParam } from "@/hooks/use-url-state"; -import { cn } from "@/lib/utils"; +import { useCallback, useEffect, useState } from "react"; +import { fetchSchema, type StudioSchema } from "@/api"; +import { LoadingScreen } from "@/components/LoadingScreen"; +import { Overview } from "@/components/Overview"; +import { Sidebar } from "@/components/Sidebar"; +import { TableView } from "@/components/TableView"; +import { + setUrlParams, + TABLE_VIEW_PARAMS, + useUrlParam, +} from "@/hooks/use-url-state"; +import type { FilterCondition } from "@/lib/filters"; + +export type OpenTable = (name: string, filters?: FilterCondition[]) => void; export function App() { - const [tables, setTables] = useState([]); - const [menuOpen, setMenuOpen] = useState(false); - const [search, setSearch] = useState(""); - const [loading, setLoading] = useState(true); + const [schema, setSchema] = useState(null); const [error, setError] = useState(null); - const menuRef = useRef(null); - - // URL state - const openParam = useUrlParam("open"); - const activeTab = useUrlParam("tab"); - const openTabs = openParam ? openParam.split(",").filter(Boolean) : []; + const activeName = useUrlParam("table"); useEffect(() => { - fetchTables() - .then(setTables) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); + fetchSchema() + .then(setSchema) + .catch((e) => setError(e instanceof Error ? e.message : String(e))); }, []); - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setMenuOpen(false); - } - } - if (menuOpen) { - document.addEventListener("mousedown", handleClickOutside); - } else { - setSearch(""); - } - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [menuOpen]); - - function openTable(name: string) { - const next = openTabs.includes(name) ? openTabs : [...openTabs, name]; - setUrlParams({ - open: next.join(","), - tab: name, - filters: null, - page: null, - }); - setMenuOpen(false); - } + const openTable = useCallback((name, filters) => { + setUrlParams( + { + table: name, + ...TABLE_VIEW_PARAMS, + filters: filters?.length ? JSON.stringify(filters) : null, + }, + { push: true }, + ); + }, []); - function closeTab(name: string) { - const next = openTabs.filter((t) => t !== name); - let newActive = activeTab; - if (activeTab === name) { - const idx = openTabs.indexOf(name); - newActive = next[Math.min(idx, next.length - 1)] ?? null; + useEffect(() => { + if (schema) { + document.title = activeName + ? `${activeName} · Database Studio` + : "Database Studio"; } - setUrlParams({ - open: next.length ? next.join(",") : null, - tab: newActive, - filters: null, - page: null, - }); - } + }, [schema, activeName]); - function switchTab(name: string) { - setUrlParams({ - tab: name, - filters: null, - page: null, - }); - } - - if (loading || error) { + if (!schema) { return ; } - const unopenedTables = tables.filter((t) => !openTabs.includes(t.name)); - - const hasOpenTabs = openTabs.length > 0; + const active = schema.tables.find((t) => t.name === activeName) ?? null; return ( -
- {hasOpenTabs && ( -
-
- -
- - {unopenedTables.length > 0 && ( -
- - {menuOpen && ( -
-
- setSearch(e.target.value)} - className="h-7 text-xs" - autoFocus - /> -
-
- {unopenedTables - .filter((t) => - t.name.toLowerCase().includes(search.toLowerCase()), - ) - .map((table) => ( - - ))} -
-
- )} -
- )} -
- )} -
- {activeTab ? ( - +
+ openTable(name)} + /> +
+ {active ? ( + ) : ( -
-
-
-

- Tables -

-

- {tables.length} table{tables.length !== 1 ? "s" : ""} found -

-
- {tables.map((table, i) => ( - - ))} -
-
-
-
+ openTable(name)} + /> )}
diff --git a/packages/database-studio/client/src/api.ts b/packages/database-studio/client/src/api.ts index fa7e184d..19f878ab 100644 --- a/packages/database-studio/client/src/api.ts +++ b/packages/database-studio/client/src/api.ts @@ -1,261 +1,101 @@ -import type { OpenAPISpec } from "@bunny.net/database-openapi"; - -export interface TableSummary { - name: string; -} - -export interface ColumnSchema { - name: string; - type: string; - notnull: boolean; - defaultValue: string | null; - primaryKey: boolean; - nullable: boolean; +import type { + ColumnDefinition, + TableDefinition, +} from "@bunny.net/database-openapi"; +import { + type FilterCondition, + type FilterMode, + filterParams, +} from "@/lib/filters"; + +export type { ColumnDefinition, TableDefinition }; + +export type Row = Record; + +export interface StudioSchema { + /** Which database is open, e.g. `db_… (host)`. */ + label: string | null; + readOnly: boolean; + tables: TableDefinition[]; } -export interface TableSchema { - columns: ColumnSchema[]; - foreignKeys: { from: string; table: string; to: string }[]; - indexes: { name: string; unique: boolean }[]; +export interface RowsQuery { + page: number; + limit: number; + filters: FilterCondition[]; + mode: FilterMode; + sort?: { column: string; order: "asc" | "desc" }; } export interface RowsResponse { - columns: string[]; - rows: Record[]; - pagination: { - page: number; - limit: number; - totalRows: number; - totalPages: number; - }; + rows: Row[]; + totalRows: number; + totalPages: number; responseTime: number; } -const BASE = ""; - -let cachedSpec: OpenAPISpec | null = null; - -const fetchSpec = async (): Promise => { - if (cachedSpec) return cachedSpec; - const res = await fetch(`${BASE}/api/`); - if (!res.ok) throw new Error(`Failed to load API spec: ${res.status}`); - const spec: OpenAPISpec = await res.json(); - cachedSpec = spec; - return spec; -}; - -export const fetchTables = async (): Promise => { - const spec = await fetchSpec(); - // Extract table names from paths - each /{table} path is a table - const tables: TableSummary[] = []; - for (const path of Object.keys(spec.paths)) { - // Match /{tableName} but not /{tableName}/{id} or /{tableName}/by-* - const match = path.match(/^\/([^/]+)$/); - if (match?.[1]) { - tables.push({ name: match[1] }); - } - } - return tables.sort((a, b) => a.name.localeCompare(b.name)); -}; - -export const fetchTableSchema = async (name: string): Promise => { - const spec = await fetchSpec(); - const tableSchema = spec.components.schemas[name]; - if (!tableSchema?.properties) { - throw new Error(`Schema not found for table: ${name}`); - } - - // Build columns from the OpenAPI schema - const insertSchema = spec.components.schemas[`${name}Insert`]; - const required = new Set(tableSchema.required ?? []); - const insertProps = new Set(Object.keys(insertSchema?.properties ?? {})); +async function request(url: string, signal?: AbortSignal): Promise { + const res = await fetch(url, { signal, credentials: "same-origin" }); + if (res.ok) return res; - const columns: ColumnSchema[] = Object.entries(tableSchema.properties).map( - ([colName, colSchema]) => { - // A column is a PK if it's in the base schema but not in the insert schema - // (INTEGER PKs are auto-increment and excluded from insert) - const isPk = !insertProps.has(colName); - - return { - name: colName, - type: mapOpenAPIType(colSchema.type, colSchema.format), - notnull: !colSchema.nullable && required.has(colName), - defaultValue: null, - primaryKey: isPk, - nullable: colSchema.nullable ?? false, - }; - }, - ); - - const foreignKeys: TableSchema["foreignKeys"] = []; - for (const [colName, colSchema] of Object.entries(tableSchema.properties)) { - const fk = colSchema["x-foreign-key"]; - if (fk) { - foreignKeys.push({ from: colName, table: fk.table, to: fk.column }); - } + if (res.status === 401) { + throw new Error( + "This studio session has expired. Run `bunny db studio` again to reconnect.", + ); } - - const indexes: TableSchema["indexes"] = - tableSchema["x-indexes"]?.map((idx) => ({ - name: idx.name, - unique: idx.unique, - })) ?? []; - - return { - columns, - foreignKeys, - indexes, - }; -}; - -const mapOpenAPIType = (type?: string, format?: string): string => { - if (type === "integer") return "INTEGER"; - if (type === "number") return "REAL"; - if (type === "boolean") return "BOOLEAN"; - if (type === "string" && format === "date-time") return "DATETIME"; - if (type === "string" && format === "binary") return "BLOB"; - if (type === "string") return "TEXT"; - return type ?? "TEXT"; -}; - -export interface RowLookupResponse { - row: Record; - columns: string[]; - schema: { name: string; type: string }[]; - foreignKeys: { from: string; table: string; to: string }[]; + let message = `Request failed (${res.status})`; + try { + const body = await res.json(); + if (body?.message) message = body.message; + } catch {} + throw new Error(message); } -export const fetchRowLookup = async ( - table: string, - column: string, - value: string, -): Promise => { - const res = await fetch( - `${BASE}/api/${encodeURIComponent(table)}?${encodeURIComponent(column)}=eq.${encodeURIComponent(value)}&limit=1`, - ); - if (!res.ok) throw new Error(`Lookup failed: ${res.status}`); - const body = await res.json(); - const rows = body.data as Record[]; - const [row] = rows; - if (!row) throw new Error("Row not found"); - - const tableSchema = await fetchTableSchema(table); - - return { - row, - columns: Object.keys(row), - schema: tableSchema.columns.map((c) => ({ name: c.name, type: c.type })), - foreignKeys: tableSchema.foreignKeys, - }; -}; - -export interface FilterCondition { - column: string; - operator: string; - value: string; +export async function fetchSchema(): Promise { + const res = await request("/studio-api/schema"); + return res.json(); } -export type FilterMode = "and" | "or"; - -export const FILTER_OPERATORS = new Set([ - "=", - "!=", - ">", - "<", - ">=", - "<=", - "LIKE", - "NOT LIKE", - "IS NULL", - "IS NOT NULL", -]); - -// Map studio UI operators to PostgREST query param format -const mapFilter = (f: FilterCondition): [string, string] => { - switch (f.operator) { - case "=": - return [f.column, `eq.${f.value}`]; - case "!=": - return [f.column, `neq.${f.value}`]; - case ">": - return [f.column, `gt.${f.value}`]; - case "<": - return [f.column, `lt.${f.value}`]; - case ">=": - return [f.column, `gte.${f.value}`]; - case "<=": - return [f.column, `lte.${f.value}`]; - case "LIKE": - return [f.column, `like.${f.value}`]; - case "NOT LIKE": - return [f.column, `neq.${f.value}`]; // approximate - case "IS NULL": - return [f.column, "is.null"]; - case "IS NOT NULL": - // Use neq approach - filter where column is not null - return [f.column, `neq.`]; // We'll handle this specially - default: - return [f.column, `eq.${f.value}`]; - } -}; - -export const fetchTableRows = async ( - name: string, - page = 1, - limit = 50, - filters: FilterCondition[] = [], - sort?: { column: string; order: "asc" | "desc" }, - _filterMode: FilterMode = "and", -): Promise => { +export async function fetchRows( + table: string, + query: RowsQuery, + signal?: AbortSignal, +): Promise { const start = performance.now(); - const offset = (page - 1) * limit; - const params = new URLSearchParams(); - params.set("limit", String(limit)); - params.set("offset", String(offset)); - - if (sort) { - params.set("order", `${sort.column}.${sort.order}`); - } - - for (const f of filters) { - // Handle IS NOT NULL specially - the rest handler doesn't have a "not null" operator, - // but we can skip it or handle it via is.false workaround - if (f.operator === "IS NULL") { - params.append(f.column, "is.null"); - } else if (f.operator === "IS NOT NULL") { - // No direct PostgREST equivalent for IS NOT NULL - skip for now - // TODO: Add "not" operator support to database-rest - } else { - const [key, value] = mapFilter(f); - params.append(key, value); - } + params.set("limit", String(query.limit)); + params.set("offset", String((query.page - 1) * query.limit)); + if (query.sort) { + params.set("order", `${query.sort.column}.${query.sort.order}`); } - - const res = await fetch(`${BASE}/api/${encodeURIComponent(name)}?${params}`); - if (!res.ok) { - let message = `API error: ${res.status}`; - try { - const body = await res.json(); - if (body?.message) message = body.message; - } catch {} - throw new Error(message); + for (const [key, value] of filterParams(query.filters, query.mode)) { + params.append(key, value); } - const body = await res.json(); + const res = await request( + `/api/${encodeURIComponent(table)}?${params}`, + signal, + ); + const body = (await res.json()) as { data: Row[] }; const totalRows = Number(res.headers.get("X-Total-Count") ?? 0); - const rows = body.data as Record[]; - const columns = rows[0] ? Object.keys(rows[0]) : []; return { - columns, - rows, - pagination: { - page, - limit, - totalRows, - totalPages: Math.max(1, Math.ceil(totalRows / limit)), - }, + rows: body.data, + totalRows, + totalPages: Math.max(1, Math.ceil(totalRows / query.limit)), responseTime: Math.round(performance.now() - start), }; -}; +} + +/** The first row where `column` equals `value`, or null; used to follow foreign keys. */ +export async function fetchRowWhere( + table: string, + column: string, + value: unknown, +): Promise { + const params = new URLSearchParams({ limit: "1" }); + params.set(column, `eq.${String(value)}`); + const res = await request(`/api/${encodeURIComponent(table)}?${params}`); + const body = (await res.json()) as { data: Row[] }; + return body.data[0] ?? null; +} diff --git a/packages/database-studio/client/src/components/CellValue.tsx b/packages/database-studio/client/src/components/CellValue.tsx new file mode 100644 index 00000000..251797d4 --- /dev/null +++ b/packages/database-studio/client/src/components/CellValue.tsx @@ -0,0 +1,42 @@ +import { base64Size, formatBytes, parseJsonObject } from "@/lib/format"; + +/** + * Renders a value by its column type. `inline` is the one-line grid form; + * otherwise long text wraps and JSON is pretty-printed. + */ +export function CellValue({ + value, + type, + inline = false, +}: { + value: unknown; + type: string; + inline?: boolean; +}) { + if (value === null || value === undefined) { + return NULL; + } + if (type === "BLOB" && typeof value === "string") { + return ( + + BLOB · {formatBytes(base64Size(value))} + + ); + } + if (typeof value === "boolean") { + return {String(value)}; + } + if (typeof value === "number") { + return {String(value)}; + } + if (value === "") { + return empty; + } + + const text = typeof value === "string" ? value : JSON.stringify(value); + if (!inline) { + const json = parseJsonObject(value); + if (json) return <>{JSON.stringify(json, null, 2)}; + } + return <>{text}; +} diff --git a/packages/database-studio/client/src/components/ColumnToggleMenu.tsx b/packages/database-studio/client/src/components/ColumnToggleMenu.tsx deleted file mode 100644 index d6d4fc1f..00000000 --- a/packages/database-studio/client/src/components/ColumnToggleMenu.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { VisibilityState } from "@tanstack/react-table"; -import { FadeScrollArea } from "@/components/FadeScrollArea"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; - -export function ColumnToggleMenu({ - columns, - visibility, - onChange, - onClose, -}: { - columns: string[]; - visibility: VisibilityState; - onChange: (v: VisibilityState) => void; - onClose: () => void; -}) { - const hiddenCount = Object.values(visibility).filter((v) => !v).length; - - return ( - <> - - ); - })} - - {hiddenCount > 0 && ( -
- -
- )} -
- - ); -} diff --git a/packages/database-studio/client/src/components/DataGrid.tsx b/packages/database-studio/client/src/components/DataGrid.tsx new file mode 100644 index 00000000..708c1c1a --- /dev/null +++ b/packages/database-studio/client/src/components/DataGrid.tsx @@ -0,0 +1,234 @@ +import { + ArrowDown, + ArrowRight, + ArrowUp, + Check, + Copy, + KeyRound, + Link2, + Maximize2, +} from "lucide-react"; +import { useState } from "react"; +import type { ColumnDefinition, Row, TableDefinition } from "@/api"; +import { CellValue } from "@/components/CellValue"; +import { toText } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +const NUMERIC = new Set(["INTEGER", "REAL"]); + +export function DataGrid({ + table, + columns, + rows, + offset, + sort, + onSort, + selectedRow, + onOpenRow, + onFollowForeignKey, + emptyMessage, +}: { + table: TableDefinition; + columns: ColumnDefinition[]; + rows: Row[]; + offset: number; + sort?: { column: string; order: string }; + onSort: (column: string) => void; + selectedRow: number | null; + onOpenRow: (index: number, row: Row) => void; + onFollowForeignKey: ( + column: string, + value: unknown, + ) => Promise; + emptyMessage: string; +}) { + const foreignKeys = new Map(table.foreignKeys.map((fk) => [fk.column, fk])); + const primaryKey = new Set(table.primaryKey); + + return ( +
+ + + + + ); + })} + {/* Takes up spare width so data columns size to their content. */} + + + + {rows.map((row, i) => ( + { + // Let people select text in a cell without opening the row. + if (window.getSelection()?.toString()) return; + onOpenRow(i, row); + }} + className={cn( + "group/row cursor-default", + selectedRow === i ? "bg-accent" : "hover:bg-accent/40", + )} + > + + {columns.map((col) => ( + onFollowForeignKey(col.name, row[col.name])} + /> + ))} + + ))} + +
+ {columns.map((col) => { + const fk = foreignKeys.get(col.name); + const sorted = sort?.column === col.name ? sort.order : null; + return ( + + + +
+ + {(offset + i + 1).toLocaleString()} + + + +
+ {rows.length === 0 && ( +

+ {emptyMessage} +

+ )} +
+ ); +} + +function GridCell({ + column, + value, + foreignKey, + onFollow, +}: { + column: ColumnDefinition; + value: unknown; + foreignKey: boolean; + onFollow: () => Promise; +}) { + const [copied, setCopied] = useState(false); + const [followError, setFollowError] = useState(null); + const hasValue = value !== null && value !== undefined; + + return ( + +
+
+ +
+ {hasValue && ( + + )} + {foreignKey && hasValue && ( + + )} +
+ + ); +} diff --git a/packages/database-studio/client/src/components/DataTab.tsx b/packages/database-studio/client/src/components/DataTab.tsx deleted file mode 100644 index 6da1885f..00000000 --- a/packages/database-studio/client/src/components/DataTab.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import type { - ColumnDef, - SortingState, - VisibilityState, -} from "@tanstack/react-table"; -import { ExternalLink } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { fetchRowLookup, type RowsResponse, type TableSchema } from "@/api.ts"; -import { FadeScrollArea } from "@/components/FadeScrollArea"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { DataTable } from "@/components/ui/data-table"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; - -type RowRecord = Record; - -function CellValue({ value }: { value: unknown }) { - if (value === null || value === undefined) { - return NULL; - } - if (typeof value === "boolean") { - return ( - - {String(value)} - - ); - } - if (typeof value === "number" || typeof value === "bigint") { - return {String(value)}; - } - const str = String(value); - if (str.length > 200) { - return {str.slice(0, 200)}...; - } - return <>{str}; -} - -interface SheetEntry { - id: string; - tableName: string; - row: Record; - columnTypes: Map; - foreignKeys: Map; -} - -export function DataTab({ - data, - schema, - sorting, - onSortingChange, - columnVisibility, - onColumnVisibilityChange, -}: { - data: RowsResponse | null; - schema: TableSchema | null; - sorting: SortingState; - onSortingChange: (s: SortingState) => void; - columnVisibility: VisibilityState; - onColumnVisibilityChange: (v: VisibilityState) => void; -}) { - const [sheetStack, setSheetStack] = useState([]); - - const columnTypes = useMemo(() => { - if (!schema) return new Map(); - return new Map(schema.columns.map((c) => [c.name, c.type || "ANY"])); - }, [schema]); - - const primaryKeys = useMemo(() => { - if (!schema) return new Set(); - return new Set( - schema.columns.filter((c) => c.primaryKey).map((c) => c.name), - ); - }, [schema]); - - const foreignKeys = useMemo(() => { - if (!schema) return new Map(); - return new Map( - schema.foreignKeys.map((fk) => [fk.from, { table: fk.table, to: fk.to }]), - ); - }, [schema]); - - function openRow( - row: RowRecord, - tableName: string, - types: Map, - fks: Map, - ) { - setSheetStack((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - tableName, - row, - columnTypes: types, - foreignKeys: fks, - }, - ]); - } - - function closeSheet(index: number) { - setSheetStack((prev) => prev.slice(0, index)); - } - - const followFkRef = - useRef<(fk: { table: string; to: string }, value: unknown) => void>(); - followFkRef.current = async (fk, value) => { - if (value === null || value === undefined) return; - try { - const result = await fetchRowLookup(fk.table, fk.to, String(value)); - const types = new Map( - result.schema.map((c) => [c.name, c.type || "ANY"]), - ); - const fks = new Map( - result.foreignKeys.map((f) => [f.from, { table: f.table, to: f.to }]), - ); - openRow(result.row as RowRecord, fk.table, types, fks); - } catch { - // Row not found or error — ignore - } - }; - - const followFk = useCallback( - (fk: { table: string; to: string }, value: unknown) => { - followFkRef.current?.(fk, value); - }, - [], - ); - - const columns = useMemo[]>(() => { - if (!data) return []; - return data.columns.map((col) => { - const fk = foreignKeys.get(col); - const colType = columnTypes.get(col) ?? "ANY"; - const isPk = primaryKeys.has(col); - return { - accessorKey: col, - header: () => ( -
- {col} - {isPk && ( - - PK - - )} - {fk && ( - - FK - - )} - - {colType} - -
- ), - cell: ({ getValue }) => { - const value = getValue(); - if (fk && value !== null && value !== undefined) { - return ( - - ); - } - return ; - }, - }; - }); - }, [data?.columns, foreignKeys, columnTypes, primaryKeys, followFk, data]); - - if (!data) return null; - - return ( - <> -
- openRow(row, "", columnTypes, foreignKeys)} - /> -
- {sheetStack.map((entry, i) => ( - { - if (!open) closeSheet(i); - }} - > - 0} - className="flex flex-col overflow-hidden p-0 sm:max-w-md transition-transform" - style={{ - zIndex: 50 + i, - transform: `translateX(-${(sheetStack.length - 1 - i) * 24}px)`, - }} - onInteractOutside={(e) => { - if (i === sheetStack.length - 1) { - closeSheet(i); - } else { - e.preventDefault(); - } - }} - > - - - {entry.tableName ? `${entry.tableName}` : "Row Detail"} - - - Field values for the selected row - - - -
- {Object.entries(entry.row).map(([key, value]) => { - const fk = entry.foreignKeys.get(key); - return ( -
-
- - {key} - - {fk && ( - - FK → {fk.table}.{fk.to} - - )} - - {entry.columnTypes.get(key) ?? "ANY"} - -
-
-
- -
- {fk && value !== null && value !== undefined && ( - - )} -
-
- ); - })} -
-
-
-
- ))} - - ); -} diff --git a/packages/database-studio/client/src/components/DatabaseIcon.tsx b/packages/database-studio/client/src/components/DatabaseIcon.tsx new file mode 100644 index 00000000..5fa1c71a --- /dev/null +++ b/packages/database-studio/client/src/components/DatabaseIcon.tsx @@ -0,0 +1,18 @@ +export function DatabaseIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/packages/database-studio/client/src/components/FadeScrollArea.tsx b/packages/database-studio/client/src/components/FadeScrollArea.tsx deleted file mode 100644 index 13ef2271..00000000 --- a/packages/database-studio/client/src/components/FadeScrollArea.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useRef, useState } from "react"; -import { cn } from "@/lib/utils"; - -/** - * A scroll container with a fade-out gradient at the top when scrolled. - */ -export function FadeScrollArea({ - className, - children, -}: { - className?: string; - children: React.ReactNode; -}) { - const [scrolled, setScrolled] = useState(false); - const ref = useRef(null); - - return ( -
-
-
setScrolled((ref.current?.scrollTop ?? 0) > 0)} - > - {children} -
-
- ); -} diff --git a/packages/database-studio/client/src/components/FilterBar.tsx b/packages/database-studio/client/src/components/FilterBar.tsx index 1c1c8ed7..432fcb47 100644 --- a/packages/database-studio/client/src/components/FilterBar.tsx +++ b/packages/database-studio/client/src/components/FilterBar.tsx @@ -1,9 +1,7 @@ -import { Plus, Trash2 } from "lucide-react"; -import { useEffect, useRef } from "react"; -import type { FilterCondition, FilterMode } from "@/api.ts"; +import { Plus, X } from "lucide-react"; +import { useState } from "react"; +import type { ColumnDefinition } from "@/api"; import { Button } from "@/components/ui/button"; -import { ButtonGroup } from "@/components/ui/button-group"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -11,241 +9,202 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + type FilterCondition, + type FilterMode, + isComplete, + isNullary, + OPERATOR_IDS, + OPERATORS, + type OperatorId, +} from "@/lib/filters"; +import { cn } from "@/lib/utils"; -const OPERATORS = [ - { value: "=", label: "=" }, - { value: "!=", label: "!=" }, - { value: ">", label: ">" }, - { value: "<", label: "<" }, - { value: ">=", label: ">=" }, - { value: "<=", label: "<=" }, - { value: "LIKE", label: "LIKE" }, - { value: "NOT LIKE", label: "NOT LIKE" }, - { value: "IS NULL", label: "IS NULL" }, - { value: "IS NOT NULL", label: "IS NOT NULL" }, -]; +interface Draft extends FilterCondition { + id: number; +} -export const NULLARY_OPERATORS = new Set(["IS NULL", "IS NOT NULL"]); +let nextId = 0; +const draft = (f: FilterCondition): Draft => ({ ...f, id: nextId++ }); -export interface FilterBarProps { - columns: { name: string; type: string }[]; - appliedFilters: FilterCondition[]; - filterRowCount: number; - onFilterRowCountChange: (count: number) => void; - filterMode: FilterMode; - onApply: ( - refs: Map< - number, - { column: string; operator: string; valueRef: HTMLInputElement | null } - >, - mode?: FilterMode, - ) => void; +/** A sensible first operator: text columns are usually searched, not matched exactly. */ +function defaultOperator(column?: ColumnDefinition): OperatorId { + return column?.type === "TEXT" ? "contains" : "eq"; } export function FilterBar({ columns, - appliedFilters, - filterRowCount, - onFilterRowCountChange, - filterMode, + applied, + mode, onApply, -}: FilterBarProps) { - // Store refs for each filter row's select/input values - const rowRefs = useRef< - Map< - number, - { column: string; operator: string; valueRef: HTMLInputElement | null } - > - >(new Map()); - - // Initialize refs from applied filters - useEffect(() => { - for (let i = 0; i < appliedFilters.length; i++) { - if (!rowRefs.current.has(i)) { - rowRefs.current.set(i, { - column: appliedFilters[i].column, - operator: appliedFilters[i].operator, - valueRef: null, - }); - } - } - }, [appliedFilters]); - - const count = Math.max(filterRowCount, 0); - const indices = Array.from({ length: count }, (_, i) => i); +}: { + columns: ColumnDefinition[]; + applied: FilterCondition[]; + mode: FilterMode; + onApply: (filters: FilterCondition[], mode: FilterMode) => void; +}) { + // Re-mounted (via key) whenever the applied filters change, so drafts start + // from what's in the URL. + const [drafts, setDrafts] = useState(() => + applied.length > 0 + ? applied.map(draft) + : [ + draft({ + column: columns[0]?.name ?? "", + operator: defaultOperator(columns[0]), + value: "", + }), + ], + ); - function addRow() { - const newIndex = count; - rowRefs.current.set(newIndex, { - column: columns[0]?.name ?? "", - operator: "=", - valueRef: null, - }); - onFilterRowCountChange(count + 1); - } + const strip = (ds: Draft[]): FilterCondition[] => + ds.map(({ column, operator, value }) => ({ column, operator, value })); + const dirty = + JSON.stringify(strip(drafts).filter(isComplete)) !== + JSON.stringify(applied); - function removeRow(index: number) { - rowRefs.current.delete(index); - const remaining = indices.filter((i) => i !== index); - const newRefs = new Map< - number, - { column: string; operator: string; valueRef: HTMLInputElement | null } - >(); - remaining.forEach((oldIdx, newIdx) => { - const ref = rowRefs.current.get(oldIdx); - if (ref) newRefs.set(newIdx, ref); - }); - rowRefs.current = newRefs; - onFilterRowCountChange(remaining.length); - onApply(newRefs); + function update(id: number, patch: Partial) { + setDrafts((ds) => ds.map((d) => (d.id === id ? { ...d, ...patch } : d))); } - function clearAll() { - rowRefs.current.clear(); - onFilterRowCountChange(0); - onApply(new Map()); + function remove(id: number) { + const next = drafts.filter((d) => d.id !== id); + setDrafts(next); + onApply(strip(next), mode); } - function apply() { - onApply(rowRefs.current); + function add() { + const column = columns[0]; + setDrafts((ds) => [ + ...ds, + draft({ + column: column?.name ?? "", + operator: defaultOperator(column), + value: "", + }), + ]); } return ( -
- {indices.map((i) => { - const applied = appliedFilters[i]; - const ref = rowRefs.current.get(i); - const defaultColumn = - ref?.column ?? applied?.column ?? columns[0]?.name ?? ""; - const defaultOperator = ref?.operator ?? applied?.operator ?? "="; - const defaultValue = applied?.value ?? ""; - - if (!rowRefs.current.has(i)) { - rowRefs.current.set(i, { - column: defaultColumn, - operator: defaultOperator, - valueRef: null, - }); - } - - return ( -
- - - { - const r = rowRefs.current.get(i); - if (r) r.valueRef = el; - }} - defaultValue={defaultValue} +
+ {drafts.map((d, i) => ( +
+ + {i === 0 ? ( + "where" + ) : i === 1 ? ( + + ) : ( + {mode} + )} + + + + {!isNullary(d.operator) && ( + update(d.id, { value: e.target.value })} onKeyDown={(e) => { - if (e.key === "Enter") apply(); + if (e.key === "Enter") onApply(strip(drafts), mode); }} - placeholder="Value..." - className="h-7 w-40 font-mono text-xs" + placeholder={d.operator === "in" ? "a, b, c" : "value"} + aria-label="Filter value" + // biome-ignore lint/a11y/noAutofocus: a freshly added filter is about to be typed into + autoFocus={d.value === "" && i === drafts.length - 1} + className="h-7 w-56 rounded-md border bg-background px-2 font-mono text-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" /> - -
- ); - })} -
+ )} + +
+ ))} +
- {count > 0 && ( - <> - {count > 1 && ( - - - - - )} - - - + + {applied.length > 0 && ( + + )} + {dirty && ( + + Press Enter to apply + )}
diff --git a/packages/database-studio/client/src/components/Overview.tsx b/packages/database-studio/client/src/components/Overview.tsx new file mode 100644 index 00000000..59518ae5 --- /dev/null +++ b/packages/database-studio/client/src/components/Overview.tsx @@ -0,0 +1,77 @@ +import { KeyRound, Link2, Table2 } from "lucide-react"; +import type { TableDefinition } from "@/api"; + +export function Overview({ + tables, + missing, + onSelect, +}: { + tables: TableDefinition[]; + /** A table named in the URL that isn't in the schema. */ + missing: string | null; + onSelect: (name: string) => void; +}) { + return ( +
+
+

Tables

+

+ {missing + ? `There's no table named “${missing}”. Pick one below.` + : "Pick a table to browse its rows and schema."} +

+ + {tables.length === 0 ? ( +
+

+ This database has no tables yet +

+

+ Create one with{" "} + bunny db shell{" "} + or a migration, then restart studio. +

+
+ ) : ( +
+ {tables.map((table) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/packages/database-studio/client/src/components/Popover.tsx b/packages/database-studio/client/src/components/Popover.tsx new file mode 100644 index 00000000..27eb7f3e --- /dev/null +++ b/packages/database-studio/client/src/components/Popover.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +/** + * A panel anchored under its trigger. Closes on outside click or Escape. + * Render it inside a `relative` wrapper alongside the trigger. + */ +export function Popover({ + open, + onClose, + align = "left", + className, + children, +}: { + open: boolean; + onClose: () => void; + align?: "left" | "right"; + className?: string; + children: React.ReactNode; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function onPointerDown(e: PointerEvent) { + // Clicks on the trigger (the panel's sibling) toggle it themselves. + const wrapper = ref.current?.parentElement; + if (wrapper && !wrapper.contains(e.target as Node)) onClose(); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ {children} +
+ ); +} + +export function MenuItem({ + onClick, + children, + className, +}: { + onClick: () => void; + children: React.ReactNode; + className?: string; +}) { + return ( + + ); +} diff --git a/packages/database-studio/client/src/components/RowSheet.tsx b/packages/database-studio/client/src/components/RowSheet.tsx new file mode 100644 index 00000000..8ed6a989 --- /dev/null +++ b/packages/database-studio/client/src/components/RowSheet.tsx @@ -0,0 +1,304 @@ +import { + ArrowLeft, + ArrowRight, + Check, + Copy, + Filter, + KeyRound, + Link2, +} from "lucide-react"; +import { useState } from "react"; +import type { OpenTable } from "@/App"; +import type { Row, TableDefinition } from "@/api"; +import { CellValue } from "@/components/CellValue"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { toText } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +export interface SheetEntry { + table: TableDefinition; + row: Row; +} + +function useCopy() { + const [copied, setCopied] = useState(null); + return { + copied, + copy(key: string, text: string) { + navigator.clipboard.writeText(text).then(() => { + setCopied(key); + setTimeout(() => setCopied((c) => (c === key ? null : c)), 1200); + }); + }, + }; +} + +function rowLabel({ table, row }: SheetEntry): string { + if (table.primaryKey.length === 0) return table.name; + const key = table.primaryKey.map((c) => toText(row[c])).join(", "); + return `${table.name} #${key}`; +} + +export function RowSheet({ + stack, + tables, + onBack, + onClose, + onFollowForeignKey, + onOpenTable, +}: { + stack: SheetEntry[]; + tables: TableDefinition[]; + onBack: () => void; + onClose: () => void; + onFollowForeignKey: ( + table: TableDefinition, + column: string, + value: unknown, + ) => Promise; + onOpenTable: OpenTable; +}) { + const entry = stack.at(-1); + const { copied, copy } = useCopy(); + const [followErrors, setFollowErrors] = useState>({}); + + const incoming = entry + ? tables.flatMap((t) => + t.foreignKeys + .filter((fk) => fk.referencesTable === entry.table.name) + .map((fk) => ({ + table: t.name, + column: fk.column, + to: fk.referencesColumn, + })), + ) + : []; + + return ( + { + if (!open) onClose(); + }} + > + {entry && ( + { + // Clicking another row swaps the sheet's contents instead of closing it. + if ((e.target as HTMLElement).closest("tbody")) e.preventDefault(); + }} + > + + {stack.length > 1 && ( + + )} +
+ + {rowLabel(entry)} + + +
+ + Field values for the selected row + +
+ +
+ {entry.table.columns.map((col) => { + const value = entry.row[col.name]; + const fk = entry.table.foreignKeys.find( + (f) => f.column === col.name, + ); + const hasValue = value !== null && value !== undefined; + const errorKey = `${stack.length}:${col.name}`; + return ( +
+
+ {entry.table.primaryKey.includes(col.name) && ( + + )} + {col.name} + + {col.type} + + {fk && ( + + + {fk.referencesTable}.{fk.referencesColumn} + + )} +
+ {col.type !== "BLOB" && ( + + onOpenTable(entry.table.name, [ + hasValue + ? { + column: col.name, + operator: "eq", + value: toText(value), + } + : { + column: col.name, + operator: "null", + value: "", + }, + ]) + } + > + + + )} + {hasValue && ( + copy(col.name, toText(value))} + > + {copied === col.name ? ( + + ) : ( + + )} + + )} +
+
+
+
+ +
+ {fk && hasValue && ( + { + const err = await onFollowForeignKey( + entry.table, + col.name, + value, + ); + setFollowErrors((m) => { + const { [errorKey]: _, ...rest } = m; + return err ? { ...rest, [errorKey]: err } : rest; + }); + }} + > + + + )} +
+ {followErrors[errorKey] && ( +

+ {followErrors[errorKey]} +

+ )} +
+ ); + })} + + {incoming.length > 0 && ( +
+

+ Referenced by +

+
+ {incoming.map((ref) => { + const value = entry.row[ref.to]; + if (value === null || value === undefined) return null; + return ( + + ); + })} +
+
+ )} +
+
+ )} +
+ ); +} + +function IconButton({ + label, + onClick, + className, + children, +}: { + label: string; + onClick: () => void; + className?: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/packages/database-studio/client/src/components/SchemaTab.tsx b/packages/database-studio/client/src/components/SchemaTab.tsx index 4d3571b2..3bb6a965 100644 --- a/packages/database-studio/client/src/components/SchemaTab.tsx +++ b/packages/database-studio/client/src/components/SchemaTab.tsx @@ -1,130 +1,218 @@ -import type { TableSchema } from "@/api.ts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { KeyRound } from "lucide-react"; +import type { OpenTable } from "@/App"; +import type { TableDefinition } from "@/api"; + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
+ {children}
+
+
+ ); +} + +function Th({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Td({ + children, + mono = false, +}: { + children?: React.ReactNode; + mono?: boolean; +}) { + return ( + + {children} + + ); +} + +function TableLink({ + name, + onOpenTable, +}: { + name: string; + onOpenTable: OpenTable; +}) { + return ( + + ); +} + +/** A sole INTEGER PRIMARY KEY aliases the rowid: SQLite reports it nullable, but it never is. */ +function isRowid(table: TableDefinition, column: string): boolean { + return ( + table.primaryKey.length === 1 && + table.primaryKey[0] === column && + table.columns.find((c) => c.name === column)?.type === "INTEGER" + ); +} export function SchemaTab({ - schema, - onSelectTable, + table, + tables, + onOpenTable, }: { - schema: TableSchema | null; - onSelectTable: (name: string) => void; + table: TableDefinition; + tables: TableDefinition[]; + onOpenTable: OpenTable; }) { - if (!schema) return null; + const foreignKeys = new Map(table.foreignKeys.map((fk) => [fk.column, fk])); + const unique = new Set(table.uniqueColumns); + const incoming = tables.flatMap((t) => + t.foreignKeys + .filter((fk) => fk.referencesTable === table.name) + .map((fk) => ({ table: t.name, ...fk })), + ); return (
-

- Columns -

- - - - Name - Type - Nullable - Default - PK - - - - {schema.columns.map((col) => ( - - {col.name} - - - {col.type || "ANY"} - - - - {col.notnull ? "NOT NULL" : "NULL"} - - - {col.defaultValue ?? "-"} - - - {col.primaryKey ? ( - - PK - - ) : null} - - - ))} - -
+
+ + + Name + Type + Nullable + Default + Constraints + + + + {table.columns.map((col) => { + const fk = foreignKeys.get(col.name); + return ( + + + + {col.primaryKey && ( + + )} + {col.name} + + + + {col.type} + + + {col.nullable && !isRowid(table, col.name) ? ( + yes + ) : ( + "NOT NULL" + )} + + + {col.defaultValue === null || + col.defaultValue === undefined ? ( + — + ) : ( + String(col.defaultValue) + )} + + + + {col.primaryKey && PRIMARY KEY} + {unique.has(col.name) && UNIQUE} + {fk && ( + + →{" "} + + .{fk.referencesColumn} + + )} + + + + ); + })} + +
- {schema.foreignKeys.length > 0 && ( - <> -

- Foreign Keys -

- - - - Column - References - - - - {schema.foreignKeys.map((fk) => ( - ${fk.table}.${fk.to}`}> - {fk.from} - - - .{fk.to} - - - ))} - -
- + {table.indexes.length > 0 && ( +
+ + + Name + Columns + Unique + + + + {table.indexes.map((idx) => ( + + {idx.name} + {idx.columns.join(", ")} + + {idx.unique ? ( + UNIQUE + ) : ( + — + )} + + + ))} + +
)} - {schema.indexes.length > 0 && ( - <> -

- Indexes -

- - - - Name - Unique - - - - {schema.indexes.map((idx) => ( - - - {idx.name} - - - {idx.unique ? ( - - UNIQUE - - ) : null} - - - ))} - -
- + {incoming.length > 0 && ( +
+ + + Table + Column + References + + + + {incoming.map((ref) => ( + + + + + {ref.column} + + {table.name}.{ref.referencesColumn} + + + ))} + +
)}
); } + +function Tag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/database-studio/client/src/components/Sidebar.tsx b/packages/database-studio/client/src/components/Sidebar.tsx new file mode 100644 index 00000000..503f4ff3 --- /dev/null +++ b/packages/database-studio/client/src/components/Sidebar.tsx @@ -0,0 +1,159 @@ +import { Lock, Search, Table2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { StudioSchema } from "@/api"; +import { DatabaseIcon } from "@/components/DatabaseIcon"; +import { cn } from "@/lib/utils"; + +const isMac = /Mac|iPhone|iPad/.test(navigator.platform); + +export function Sidebar({ + schema, + activeTable, + onSelect, +}: { + schema: StudioSchema; + activeTable: string | null; + onSelect: (name: string) => void; +}) { + const [search, setSearch] = useState(""); + const [highlight, setHighlight] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const visible = useMemo(() => { + const q = search.trim().toLowerCase(); + return q + ? schema.tables.filter((t) => t.name.toLowerCase().includes(q)) + : schema.tables; + }, [schema.tables, search]); + + // Cmd/Ctrl+K jumps to the table search from anywhere. + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + useEffect(() => { + listRef.current + ?.querySelector(`[data-index="${highlight}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [highlight]); + + function onSearchKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlight((h) => Math.min(h + 1, visible.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlight((h) => Math.max(h - 1, 0)); + } else if (e.key === "Enter") { + const table = visible[highlight]; + if (table) onSelect(table.name); + } else if (e.key === "Escape") { + setSearch(""); + inputRef.current?.blur(); + } + } + + return ( + + ); +} diff --git a/packages/database-studio/client/src/components/TableView.tsx b/packages/database-studio/client/src/components/TableView.tsx index 466e2e9e..7d381af5 100644 --- a/packages/database-studio/client/src/components/TableView.tsx +++ b/packages/database-studio/client/src/components/TableView.tsx @@ -1,409 +1,559 @@ -import type { SortingState, VisibilityState } from "@tanstack/react-table"; import { ChevronLeft, ChevronRight, + ChevronsLeft, + ChevronsRight, Columns3, + Download, Filter, RefreshCw, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { OpenTable } from "@/App"; import { - FILTER_OPERATORS, - type FilterCondition, - type FilterMode, - fetchTableRows, - fetchTableSchema, + fetchRows, + fetchRowWhere, + type Row, + type RowsQuery, type RowsResponse, - type TableSchema, -} from "@/api.ts"; -import { ColumnToggleMenu } from "@/components/ColumnToggleMenu"; -import { DataTab } from "@/components/DataTab"; -import { FilterBar, NULLARY_OPERATORS } from "@/components/FilterBar"; + type TableDefinition, +} from "@/api"; +import { DataGrid } from "@/components/DataGrid"; +import { FilterBar } from "@/components/FilterBar"; +import { MenuItem, Popover } from "@/components/Popover"; +import { RowSheet, type SheetEntry } from "@/components/RowSheet"; import { SchemaTab } from "@/components/SchemaTab"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { setUrlParams, useUrlParam } from "@/hooks/use-url-state"; +import { + type FilterCondition, + type FilterMode, + isComplete, + isOperator, +} from "@/lib/filters"; +import { download, toCsv } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +const PAGE_SIZES = [25, 50, 100, 250]; +const DEFAULT_PAGE_SIZE = 50; -interface TableViewProps { - tableName: string; - onSelectTable: (name: string) => void; +function parseFilters( + raw: string | null, + columns: Set, +): FilterCondition[] { + if (!raw) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((entry): FilterCondition[] => { + const { column, operator, value } = (entry ?? {}) as Record< + string, + unknown + >; + if (typeof column !== "string" || !columns.has(column)) return []; + if (!isOperator(operator)) return []; + return [ + { column, operator, value: typeof value === "string" ? value : "" }, + ]; + }); } -export function TableView({ tableName, onSelectTable }: TableViewProps) { - const [schema, setSchema] = useState(null); +export function TableView({ + table, + tables, + onOpenTable, +}: { + table: TableDefinition; + tables: TableDefinition[]; + onOpenTable: OpenTable; +}) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [tab, setTab] = useState<"data" | "schema">("data"); - const [filtersOpen, setFiltersOpen] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + const [hidden, setHidden] = useState>(new Set()); const [columnsOpen, setColumnsOpen] = useState(false); - // Number of filter rows in the UI (may include empty/pending ones) - const [filterRowCount, setFilterRowCount] = useState(0); - const [columnVisibility, setColumnVisibility] = useState({}); + const [exportOpen, setExportOpen] = useState(false); + const [sheet, setSheet] = useState([]); + const [selectedRow, setSelectedRow] = useState(null); - // Read state from URL + const viewParam = useUrlParam("view"); const pageParam = useUrlParam("page"); const limitParam = useUrlParam("limit"); - const filtersParam = useUrlParam("filters"); - const filterModeParam = useUrlParam("filterMode"); const sortParam = useUrlParam("sort"); const orderParam = useUrlParam("order"); + const filtersParam = useUrlParam("filters"); + const modeParam = useUrlParam("mode"); - const filterMode: FilterMode = filterModeParam === "or" ? "or" : "and"; - - const page = Math.max(1, Number(pageParam) || 1); - const limit = Math.min(100, Math.max(1, Number(limitParam) || 50)); - - const columnNames = useMemo(() => { - if (!schema) return null; - return new Set(schema.columns.map((c) => c.name)); - }, [schema]); - - const sort = useMemo(() => { - if (!sortParam || !columnNames?.has(sortParam)) { - return undefined; - } - return { - column: sortParam, - order: (orderParam === "desc" ? "desc" : "asc") as "asc" | "desc", - }; - }, [sortParam, orderParam, columnNames]); - - const sortState: SortingState = useMemo(() => { - if (!sort) return []; - return [{ id: sort.column, desc: sort.order === "desc" }]; - }, [sort]); - - const appliedFilters: FilterCondition[] = useMemo(() => { - if (!filtersParam || !columnNames) return []; - let parsed: unknown; - try { - parsed = JSON.parse(filtersParam); - } catch { - return []; - } - if (!Array.isArray(parsed)) return []; - const validated: FilterCondition[] = []; - for (const entry of parsed) { - if (!entry || typeof entry !== "object") continue; - const { column, operator, value } = entry as Record; - if (typeof column !== "string" || !columnNames.has(column)) continue; - if (typeof operator !== "string" || !FILTER_OPERATORS.has(operator)) { - continue; - } - if (typeof value !== "string") continue; - validated.push({ column, operator, value }); - } - return validated; - }, [filtersParam, columnNames]); + const view = viewParam === "schema" ? "schema" : "data"; + const columnNames = useMemo( + () => new Set(table.columns.map((c) => c.name)), + [table], + ); + const filters = useMemo( + () => parseFilters(filtersParam, columnNames), + [filtersParam, columnNames], + ); + const mode: FilterMode = modeParam === "or" ? "or" : "and"; + const [filtersOpen, setFiltersOpen] = useState(filters.length > 0); - // Sync filter row count from URL on table change - useEffect(() => { - setFilterRowCount(appliedFilters.length); - if (appliedFilters.length > 0) setFiltersOpen(true); - }, [appliedFilters.length]); + const limit = PAGE_SIZES.includes(Number(limitParam)) + ? Number(limitParam) + : DEFAULT_PAGE_SIZE; + const page = Math.max(1, Math.floor(Number(pageParam)) || 1); + const sort = + sortParam && columnNames.has(sortParam) + ? { column: sortParam, order: orderParam === "desc" ? "desc" : "asc" } + : undefined; - useEffect(() => { - let cancelled = false; - setTab("data"); - setColumnVisibility({}); - setColumnsOpen(false); - setLoading(true); - setError(null); - setSchema(null); - setData(null); - fetchTableSchema(tableName) - .then((s) => { - if (cancelled) return; - setSchema(s); - }) - .catch((e) => { - if (cancelled) return; - setError(e instanceof Error ? e.message : String(e)); - setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [tableName]); + // A stable key so the fetch effect only reruns when the query changes. + const queryKey = JSON.stringify({ page, limit, filters, mode, sort }); + const query = useMemo(() => JSON.parse(queryKey) as RowsQuery, [queryKey]); + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey forces a refetch useEffect(() => { - if (!schema) return; - let cancelled = false; + const controller = new AbortController(); setLoading(true); - fetchTableRows(tableName, page, limit, appliedFilters, sort, filterMode) + fetchRows(table.name, query, controller.signal) .then((d) => { - if (cancelled) return; setData(d); setError(null); + setSelectedRow(null); }) .catch((e) => { - if (cancelled) return; + if (controller.signal.aborted) return; setError(e instanceof Error ? e.message : String(e)); }) .finally(() => { - if (cancelled) return; - setLoading(false); + if (!controller.signal.aborted) setLoading(false); }); - return () => { - cancelled = true; - }; - }, [page, limit, tableName, filterMode, sort, appliedFilters, schema]); + return () => controller.abort(); + }, [table.name, query, reloadKey]); - function refresh() { - setLoading(true); - fetchTableRows(tableName, page, limit, appliedFilters, sort, filterMode) - .then((d) => { - setData(d); - setError(null); - }) - .catch((e) => setError(e instanceof Error ? e.message : String(e))) - .finally(() => setLoading(false)); - } + const refresh = useCallback(() => setReloadKey((k) => k + 1), []); - function setPage(p: number) { - setUrlParams({ page: p === 1 ? null : String(p) }); - } + // A page past the end (a stale link, or rows deleted elsewhere) snaps back to the last one. + useEffect(() => { + if (data && data.totalRows > 0 && page > data.totalPages) { + setUrlParams({ page: String(data.totalPages) }); + } + }, [data, page]); - function setLimit(l: number) { - setUrlParams({ limit: l === 50 ? null : String(l), page: null }); + // R refreshes, like a browser reload but without losing the session. + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + const target = e.target as HTMLElement; + if (target.closest("input, textarea, select, [role=dialog]")) return; + if (e.key === "r" && !e.metaKey && !e.ctrlKey && !e.altKey) refresh(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [refresh]); + + function setPage(p: number) { + setUrlParams({ page: p <= 1 ? null : String(p) }); } - function setSorting(next: SortingState) { - if (next.length === 0) { - setUrlParams({ sort: null, order: null, page: null }); + function setSort(column: string) { + // Cycle: ascending, descending, unsorted. + if (sort?.column !== column) { + setUrlParams({ sort: column, order: null, page: null }); + } else if (sort.order === "asc") { + setUrlParams({ order: "desc", page: null }); } else { - setUrlParams({ - sort: next[0].id, - order: next[0].desc ? "desc" : null, - page: null, - }); + setUrlParams({ sort: null, order: null, page: null }); } } - function applyFiltersFromRefs( - filterRefs: Map< - number, - { column: string; operator: string; valueRef: HTMLInputElement | null } - >, - mode?: FilterMode, - ) { - const filters: FilterCondition[] = []; - for (const [, ref] of filterRefs) { - const value = ref.valueRef?.value ?? ""; - if (ref.column && (NULLARY_OPERATORS.has(ref.operator) || value !== "")) { - filters.push({ column: ref.column, operator: ref.operator, value }); - } - } - const m = mode ?? filterMode; + function applyFilters(next: FilterCondition[], nextMode: FilterMode) { + const ready = next.filter(isComplete); setUrlParams({ - filters: filters.length > 0 ? JSON.stringify(filters) : null, - filterMode: filters.length > 0 && m === "or" ? "or" : null, + filters: ready.length > 0 ? JSON.stringify(ready) : null, + mode: ready.length > 1 && nextMode === "or" ? "or" : null, page: null, }); } - if (loading && !data) { - return ( -
-

Loading...

-
- ); + const openTable = useCallback( + (name, nextFilters) => { + setSheet([]); + if (nextFilters?.length) setFiltersOpen(true); + onOpenTable(name, nextFilters); + }, + [onOpenTable], + ); + + async function followForeignKey( + from: TableDefinition, + column: string, + value: unknown, + ): Promise { + const fk = from.foreignKeys.find((f) => f.column === column); + const target = fk && tables.find((t) => t.name === fk.referencesTable); + if (!fk || !target) return "The referenced table isn't available."; + try { + const row = await fetchRowWhere(target.name, fk.referencesColumn, value); + if (!row) { + return `No row in ${target.name} where ${fk.referencesColumn} = ${String(value)}.`; + } + setSheet((s) => [...s, { table: target, row }]); + return null; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } } - if (error && !data) { - return ( -
-

- Failed to load "{tableName}" -

-

- {error} -

- -
+ const visibleColumns = table.columns.filter((c) => !hidden.has(c.name)); + const offset = (page - 1) * limit; + + function exportPage(format: "csv" | "json") { + if (!data) return; + const names = visibleColumns.map((c) => c.name); + const rows = data.rows.map((r) => + Object.fromEntries(names.map((n) => [n, r[n]])), ); + const base = `${table.name}-page-${page}`; + if (format === "csv") { + download(`${base}.csv`, toCsv(names, rows), "text/csv"); + } else { + download( + `${base}.json`, + `${JSON.stringify(rows, null, 2)}\n`, + "application/json", + ); + } + setExportOpen(false); } return ( -
-
-
- - - {tab === "data" && ( - <> +
+
+

+ {table.name} +

+
+ {(["data", "schema"] as const).map((v) => ( + + ))} +
+ + {view === "data" && ( + <> + + +
-
- - {columnsOpen && schema && ( - c.name)} - visibility={columnVisibility} - onChange={setColumnVisibility} - onClose={() => setColumnsOpen(false)} - /> + + {col.name} + + ))} +
+ {hidden.size > 0 && ( +
+ setHidden(new Set())} + className="text-muted-foreground" + > + Show all columns + +
)} -
- - )} -
+ +
+ + )} -
- {tab === "data" && data && ( - <> + {view === "data" && ( +
+
- - {data.responseTime != null && ( - - {data.responseTime}ms - - )} - - - {data.pagination.totalRows.toLocaleString()} rows - - - - - - {data.pagination.page} / {data.pagination.totalPages} - + exportPage("csv")}> + This page as CSV + + exportPage("json")}> + This page as JSON + + +
+ +
+ )} +
- - - + {view === "schema" ? ( + + ) : ( + <> + {filtersOpen && ( + )} -
-
- {error && data && ( -
- Failed to refresh: {error} -
- )} +
+ {loading && data && ( +
+
+
+ )} + {error ? ( +
+

+ Couldn't load rows from {table.name} +

+

+ {error} +

+
+ + {filters.length > 0 && ( + + )} +
+
+ ) : data ? ( + { + setSelectedRow(index); + setSheet([{ table, row }]); + }} + onFollowForeignKey={(column, value) => + followForeignKey(table, column, value) + } + emptyMessage={ + filters.length > 0 + ? "No rows match these filters." + : "This table is empty." + } + /> + ) : ( +
+ Loading rows… +
+ )} +
- {filtersOpen && tab === "data" && schema && ( - +
+ {data && ( + + {data.totalRows === 0 + ? "0 rows" + : `${(offset + 1).toLocaleString()}–${(offset + data.rows.length).toLocaleString()} of ${data.totalRows.toLocaleString()} row${data.totalRows === 1 ? "" : "s"}`} + + {data.responseTime} ms + + + )} +
+ + {data && ( +
+ setPage(1)} + > + + + setPage(page - 1)} + > + + + + Page {page.toLocaleString()} of{" "} + {data.totalPages.toLocaleString()} + + = data.totalPages} + onClick={() => setPage(page + 1)} + > + + + = data.totalPages} + onClick={() => setPage(data.totalPages)} + > + + +
+ )} +
+
+ )} - {tab === "data" ? ( - - ) : ( - - )} + setSheet((s) => s.slice(0, -1))} + onClose={() => { + setSheet([]); + setSelectedRow(null); + }} + onFollowForeignKey={followForeignKey} + onOpenTable={openTable} + />
); } + +function PagerButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/packages/database-studio/client/src/components/ui/badge.tsx b/packages/database-studio/client/src/components/ui/badge.tsx deleted file mode 100644 index 3c2a1caf..00000000 --- a/packages/database-studio/client/src/components/ui/badge.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import type * as React from "react"; -import { cn } from "@/lib/utils"; - -const badgeVariants = cva( - "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: "border-transparent bg-primary text-primary-foreground shadow", - secondary: "border-transparent bg-secondary text-secondary-foreground", - destructive: - "border-transparent bg-destructive text-destructive-foreground shadow", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -function Badge({ - className, - variant, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ); -} - -export { Badge, badgeVariants }; diff --git a/packages/database-studio/client/src/components/ui/button-group.tsx b/packages/database-studio/client/src/components/ui/button-group.tsx deleted file mode 100644 index 010a3f0e..00000000 --- a/packages/database-studio/client/src/components/ui/button-group.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; - -const buttonGroupVariants = cva( - "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10", - { - variants: { - orientation: { - horizontal: - "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", - vertical: - "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", - }, - }, - defaultVariants: { - orientation: "horizontal", - }, - }, -); - -function ButtonGroup({ - className, - orientation, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ); -} - -export { ButtonGroup, buttonGroupVariants }; diff --git a/packages/database-studio/client/src/components/ui/data-table.tsx b/packages/database-studio/client/src/components/ui/data-table.tsx deleted file mode 100644 index 8f91a34f..00000000 --- a/packages/database-studio/client/src/components/ui/data-table.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { - type ColumnDef, - flexRender, - getCoreRowModel, - type SortingState, - useReactTable, - type VisibilityState, -} from "@tanstack/react-table"; -import { - ArrowDown, - ArrowUp, - ArrowUpDown, - Check, - Copy, - Eye, - Maximize2, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -function CopyButton({ value }: { value: string }) { - const [copied, setCopied] = useState(false); - - function copy(e: React.MouseEvent) { - e.stopPropagation(); - navigator.clipboard.writeText(value).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - } - - return ( - - ); -} - -function TruncatedCell({ - children, - rawValue, - onExpand, -}: { - children: React.ReactNode; - rawValue?: string; - onExpand?: () => void; -}) { - const ref = useRef(null); - const [truncated, setTruncated] = useState(false); - - useEffect(() => { - const el = ref.current; - if (!el) return; - setTruncated(el.scrollWidth > el.clientWidth); - }); - - return ( -
-
- {children} -
- {rawValue != null && } - {truncated && onExpand && ( - - )} -
- ); -} - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - sorting?: SortingState; - onSortingChange?: (sorting: SortingState) => void; - columnVisibility?: VisibilityState; - onColumnVisibilityChange?: (visibility: VisibilityState) => void; - onInspectRow?: (row: TData) => void; -} - -export function DataTable({ - columns, - data, - sorting: externalSorting, - onSortingChange: externalOnSortingChange, - columnVisibility = {}, - onColumnVisibilityChange, - onInspectRow, -}: DataTableProps) { - const sorting = externalSorting ?? []; - - const table = useReactTable({ - data, - columns, - state: { sorting, columnVisibility }, - onSortingChange: (updater) => { - const next = typeof updater === "function" ? updater(sorting) : updater; - externalOnSortingChange?.(next); - }, - onColumnVisibilityChange: (updater) => { - const next = - typeof updater === "function" ? updater(columnVisibility) : updater; - onColumnVisibilityChange?.(next); - }, - getCoreRowModel: getCoreRowModel(), - manualSorting: true, - }); - - const colCount = columns.length + (onInspectRow ? 1 : 0); - - return ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {onInspectRow && } - {headerGroup.headers.map((header) => { - const canSort = header.column.getCanSort(); - const sorted = header.column.getIsSorted(); - return ( - - {header.isPlaceholder ? null : ( -
- - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - {canSort && - (sorted === "asc" ? ( - - ) : sorted === "desc" ? ( - - ) : ( - - ))} -
- )} -
- ); - })} -
- ))} -
- - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {onInspectRow && ( - - - - )} - {row.getVisibleCells().map((cell) => { - const raw = cell.getValue(); - const rawStr = - raw === null || raw === undefined ? undefined : String(raw); - return ( - - onInspectRow(row.original) - : undefined - } - > - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - - ); - })} - - )) - ) : ( - - - No results. - - - )} - -
- ); -} diff --git a/packages/database-studio/client/src/components/ui/input.tsx b/packages/database-studio/client/src/components/ui/input.tsx deleted file mode 100644 index 7a9c8761..00000000 --- a/packages/database-studio/client/src/components/ui/input.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - - ); - }, -); -Input.displayName = "Input"; - -export { Input }; diff --git a/packages/database-studio/client/src/components/ui/scroll-area.tsx b/packages/database-studio/client/src/components/ui/scroll-area.tsx deleted file mode 100644 index e96c2153..00000000 --- a/packages/database-studio/client/src/components/ui/scroll-area.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; -import * as React from "react"; -import { cn } from "@/lib/utils"; - -const ScrollArea = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - {children} - - - - -)); -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; - -const ScrollBar = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, orientation = "vertical", ...props }, ref) => ( - - - -)); -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; - -export { ScrollArea, ScrollBar }; diff --git a/packages/database-studio/client/src/components/ui/table.tsx b/packages/database-studio/client/src/components/ui/table.tsx deleted file mode 100644 index bd8a5390..00000000 --- a/packages/database-studio/client/src/components/ui/table.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type * as React from "react"; -import { cn } from "@/lib/utils"; - -function Table({ - className, - containerClassName, - ...props -}: React.ComponentProps<"table"> & { containerClassName?: string }) { - return ( -
- - - ); -} - -function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { - return ( - - ); -} - -function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { - return ( - - ); -} - -function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { - return ( - tr]:last:border-b-0", - className, - )} - {...props} - /> - ); -} - -function TableRow({ className, ...props }: React.ComponentProps<"tr">) { - return ( - - ); -} - -function TableHead({ className, ...props }: React.ComponentProps<"th">) { - return ( - + ); +} diff --git a/packages/database-studio/client/src/components/SchemaTab.tsx b/packages/database-studio/client/src/components/SchemaTab.tsx index 3bb6a965..d38b2288 100644 --- a/packages/database-studio/client/src/components/SchemaTab.tsx +++ b/packages/database-studio/client/src/components/SchemaTab.tsx @@ -1,4 +1,5 @@ -import { KeyRound } from "lucide-react"; +import { Check, Copy, KeyRound } from "lucide-react"; +import { useState } from "react"; import type { OpenTable } from "@/App"; import type { TableDefinition } from "@/api"; @@ -75,12 +76,16 @@ function isRowid(table: TableDefinition, column: string): boolean { export function SchemaTab({ table, tables, + ddl, onOpenTable, }: { table: TableDefinition; tables: TableDefinition[]; + ddl: string[]; onOpenTable: OpenTable; }) { + const [copied, setCopied] = useState(false); + const definition = ddl.map((s) => `${s.trim()};`).join("\n\n"); const foreignKeys = new Map(table.foreignKeys.map((fk) => [fk.column, fk])); const unique = new Set(table.uniqueColumns); const incoming = tables.flatMap((t) => @@ -205,6 +210,35 @@ export function SchemaTab({ )} + {definition && ( +
+
+

+ Definition +

+ +
+
+            {definition}
+          
+
+ )} ); } diff --git a/packages/database-studio/client/src/components/SqlConsole.tsx b/packages/database-studio/client/src/components/SqlConsole.tsx new file mode 100644 index 00000000..ec0af38f --- /dev/null +++ b/packages/database-studio/client/src/components/SqlConsole.tsx @@ -0,0 +1,322 @@ +import { Download, History, Lock, Play } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type QueryResult, runQuery, type StudioSchema } from "@/api"; +import { DataGrid } from "@/components/DataGrid"; +import { MenuItem, Popover } from "@/components/Popover"; +import { SqlEditor } from "@/components/SqlEditor"; +import { Button } from "@/components/ui/button"; +import { download, toCsv } from "@/lib/format"; +import { quoteIdent } from "@/lib/sql"; +import { cn } from "@/lib/utils"; + +const DRAFT_KEY = "bunny-studio:sql-draft"; +const HISTORY_KEY = "bunny-studio:sql-history"; +const HISTORY_LIMIT = 50; +const isMac = /Mac|iPhone|iPad/.test(navigator.platform); + +interface HistoryEntry { + sql: string; + at: number; +} + +// Storage can be unavailable (private windows, blocked site data); the console works without it. +function load(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function save(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch {} +} + +function timeAgo(at: number): string { + const s = Math.round((Date.now() - at) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return new Date(at).toLocaleDateString(); +} + +/** A request from elsewhere in the app (e.g. opening a view) to load and run SQL. */ +export interface ConsoleRequest { + sql: string; + nonce: number; +} + +export function SqlConsole({ + schema, + request, +}: { + schema: StudioSchema; + request: ConsoleRequest | null; +}) { + const [sql, setSql] = useState(() => { + const first = schema.tables[0]?.name; + return load( + DRAFT_KEY, + first ? `SELECT * FROM ${quoteIdent(first)} LIMIT 100;` : "", + ); + }); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [running, setRunning] = useState(false); + const [history, setHistory] = useState(() => + load(HISTORY_KEY, []), + ); + const [historyOpen, setHistoryOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [editorHeight, setEditorHeight] = useState(200); + const abortRef = useRef(null); + + const completion = useMemo( + () => + Object.fromEntries([ + ...schema.tables.map((t) => [t.name, t.columns.map((c) => c.name)]), + ...schema.views.map((v) => [v.name, v.columns]), + ]), + [schema], + ); + + useEffect(() => save(DRAFT_KEY, sql), [sql]); + + const run = useCallback(async (statement: string) => { + const text = statement.trim(); + if (!text) return; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setRunning(true); + setError(null); + setHistory((h) => { + const next = [ + { sql: text, at: Date.now() }, + ...h.filter((e) => e.sql !== text), + ].slice(0, HISTORY_LIMIT); + save(HISTORY_KEY, next); + return next; + }); + try { + setResult(await runQuery(text, controller.signal)); + } catch (e) { + if (controller.signal.aborted) return; + setResult(null); + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (!controller.signal.aborted) setRunning(false); + } + }, []); + + useEffect(() => { + if (!request) return; + setSql(request.sql); + run(request.sql); + }, [request, run]); + + function exportResult(format: "csv" | "json") { + if (!result) return; + const objects = result.rows.map((r) => + Object.fromEntries(result.columns.map((c, i) => [c, r[i]])), + ); + if (format === "csv") { + download("query.csv", toCsv(result.columns, result.rows), "text/csv"); + } else { + download( + "query.json", + `${JSON.stringify(objects, null, 2)}\n`, + "application/json", + ); + } + setExportOpen(false); + } + + function startResize(e: React.PointerEvent) { + e.preventDefault(); + const startY = e.clientY; + const start = editorHeight; + const onMove = (ev: PointerEvent) => + setEditorHeight( + Math.max( + 80, + Math.min(window.innerHeight - 200, start + ev.clientY - startY), + ), + ); + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + } + + const gridColumns = useMemo( + () => result?.columns.map((name) => ({ name })) ?? [], + [result], + ); + + return ( +
+
+ +
+ + setHistoryOpen(false)} + className="w-[28rem]" + > +
+ {history.map((entry) => ( + { + setSql(entry.sql); + setHistoryOpen(false); + }} + className="items-start" + > + + {entry.sql} + + + {timeAgo(entry.at)} + + + ))} +
+
+ { + setHistory([]); + save(HISTORY_KEY, []); + setHistoryOpen(false); + }} + className="text-muted-foreground" + > + Clear history + +
+
+
+ + + SELECT, WITH, EXPLAIN, PRAGMA, VALUES + +
+ +
+ +
+
+ +
+ {running && ( +
+
+
+ )} + {error ? ( +
+ {error} +
+ ) : result ? ( + + ) : ( +
+

Run a query to see results here.

+

+ Tab completes table and column names. Reads count against your + quota, so add a LIMIT to large tables. +

+
+ )} +
+ +
+ {result && !error && ( + <> + + {result.rows.length.toLocaleString()} row + {result.rows.length === 1 ? "" : "s"} + + {result.durationMs} ms + + + {result.truncated && ( + + Showing the first {result.rows.length.toLocaleString()}. Add a + LIMIT to see a specific range. + + )} + + )} +
+ + setExportOpen(false)} + align="right" + className="bottom-full top-auto mb-1 w-40" + > + exportResult("csv")}>As CSV + exportResult("json")}>As JSON + +
+
+
+ ); +} diff --git a/packages/database-studio/client/src/components/SqlEditor.tsx b/packages/database-studio/client/src/components/SqlEditor.tsx new file mode 100644 index 00000000..986f55c6 --- /dev/null +++ b/packages/database-studio/client/src/components/SqlEditor.tsx @@ -0,0 +1,189 @@ +import { + autocompletion, + closeBrackets, + closeBracketsKeymap, + completionKeymap, +} from "@codemirror/autocomplete"; +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from "@codemirror/commands"; +import { SQLite, sql } from "@codemirror/lang-sql"; +import { + bracketMatching, + HighlightStyle, + indentOnInput, + syntaxHighlighting, +} from "@codemirror/language"; +import { Compartment, EditorState, Prec } from "@codemirror/state"; +import { + drawSelection, + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + keymap, + lineNumbers, + placeholder, +} from "@codemirror/view"; +import { tags as t } from "@lezer/highlight"; +import { useEffect, useRef } from "react"; +import { statementAt } from "@/lib/sql"; + +const theme = EditorView.theme( + { + "&": { + height: "100%", + fontSize: "12px", + backgroundColor: "var(--background)", + color: "var(--foreground)", + }, + "&.cm-focused": { outline: "none" }, + ".cm-scroller": { + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + lineHeight: "1.6", + }, + ".cm-content": { caretColor: "var(--primary)" }, + ".cm-cursor": { borderLeftColor: "var(--primary)" }, + ".cm-gutters": { + backgroundColor: "var(--card)", + color: "var(--muted-foreground)", + border: "none", + borderRight: "1px solid var(--border)", + }, + ".cm-activeLine, .cm-activeLineGutter": { + backgroundColor: "color-mix(in oklch, var(--muted) 45%, transparent)", + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": + { + backgroundColor: "color-mix(in oklch, var(--primary) 28%, transparent)", + }, + ".cm-matchingBracket": { + backgroundColor: "color-mix(in oklch, var(--primary) 25%, transparent)", + outline: "none", + }, + ".cm-tooltip": { + backgroundColor: "var(--popover)", + color: "var(--popover-foreground)", + border: "1px solid var(--border)", + borderRadius: "6px", + }, + ".cm-tooltip-autocomplete ul li[aria-selected]": { + backgroundColor: "var(--accent)", + color: "var(--foreground)", + }, + ".cm-placeholder": { color: "var(--muted-foreground)" }, + }, + { dark: true }, +); + +const highlight = HighlightStyle.define([ + { tag: t.keyword, color: "var(--primary)" }, + { tag: [t.string, t.special(t.string)], color: "oklch(0.8 0.13 150)" }, + { tag: [t.number, t.bool, t.null], color: "oklch(0.8 0.11 220)" }, + { tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" }, + { tag: [t.typeName, t.standard(t.name)], color: "oklch(0.8 0.1 300)" }, + { + tag: [t.operator, t.punctuation, t.bracket], + color: "var(--muted-foreground)", + }, +]); + +/** + * CodeMirror with SQLite highlighting and completion from the schema. + * Cmd/Ctrl+Enter runs the selection, or the statement under the cursor. + */ +export function SqlEditor({ + value, + onChange, + onRun, + schema, +}: { + value: string; + onChange: (value: string) => void; + onRun: (sql: string) => void; + /** Table and view names to their columns, for autocomplete. */ + schema: Record; +}) { + const hostRef = useRef(null); + const viewRef = useRef(null); + const language = useRef(new Compartment()); + // Handlers change every render; the editor reads the latest through refs. + const onChangeRef = useRef(onChange); + const onRunRef = useRef(onRun); + onChangeRef.current = onChange; + onRunRef.current = onRun; + + // biome-ignore lint/correctness/useExhaustiveDependencies: the editor is created once; value and schema sync below + useEffect(() => { + if (!hostRef.current) return; + const run = (view: EditorView) => { + const { from, to, empty, head } = view.state.selection.main; + const doc = view.state.doc.toString(); + onRunRef.current(empty ? statementAt(doc, head) : doc.slice(from, to)); + return true; + }; + const view = new EditorView({ + parent: hostRef.current, + state: EditorState.create({ + doc: value, + extensions: [ + Prec.highest(keymap.of([{ key: "Mod-Enter", run }])), + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + drawSelection(), + history(), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...completionKeymap, + indentWithTab, + ]), + language.current.of( + sql({ dialect: SQLite, schema, upperCaseKeywords: true }), + ), + syntaxHighlighting(highlight), + theme, + placeholder("SELECT * FROM …"), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChangeRef.current(update.state.doc.toString()); + } + }), + ], + }), + }); + viewRef.current = view; + view.focus(); + return () => view.destroy(); + }, []); + + // Outside changes (history, "open view") replace the document. + useEffect(() => { + const view = viewRef.current; + if (!view || view.state.doc.toString() === value) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: value }, + selection: { anchor: value.length }, + }); + view.focus(); + }, [value]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: language.current.reconfigure( + sql({ dialect: SQLite, schema, upperCaseKeywords: true }), + ), + }); + }, [schema]); + + return
; +} diff --git a/packages/database-studio/client/src/components/TabBar.tsx b/packages/database-studio/client/src/components/TabBar.tsx index 3ec63086..881f640d 100644 --- a/packages/database-studio/client/src/components/TabBar.tsx +++ b/packages/database-studio/client/src/components/TabBar.tsx @@ -1,9 +1,10 @@ -import { Lock, Plus, X } from "lucide-react"; +import { Lock, Plus, SquareTerminal, X } from "lucide-react"; import { useEffect, useRef } from "react"; import type { StudioSchema } from "@/api"; import { DatabaseIcon } from "@/components/DatabaseIcon"; import { Popover } from "@/components/Popover"; import { TablePicker } from "@/components/TablePicker"; +import { SQL_TAB } from "@/hooks/use-url-state"; import { cn } from "@/lib/utils"; const isMac = /Mac|iPhone|iPad/.test(navigator.platform); @@ -15,8 +16,10 @@ export function TabBar({ pickerOpen, onPickerOpenChange, onSelect, + onSelectView, onClose, onHome, + onOpenSql, }: { schema: StudioSchema; openTabs: string[]; @@ -24,9 +27,12 @@ export function TabBar({ pickerOpen: boolean; onPickerOpenChange: (open: boolean) => void; onSelect: (name: string) => void; + onSelectView: (name: string) => void; onClose: (name: string) => void; onHome: () => void; + onOpenSql: () => void; }) { + const sqlActive = activeTab === SQL_TAB; const navRef = useRef(null); useEffect(() => { @@ -51,6 +57,23 @@ export function TabBar({ + +
diff --git a/packages/database-studio/client/src/components/TablePicker.tsx b/packages/database-studio/client/src/components/TablePicker.tsx index e0dbca21..d12974d5 100644 --- a/packages/database-studio/client/src/components/TablePicker.tsx +++ b/packages/database-studio/client/src/components/TablePicker.tsx @@ -1,26 +1,48 @@ -import { Search, Table2 } from "lucide-react"; +import { Eye, Search, Table2 } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { TableDefinition } from "@/api"; +import type { TableDefinition, ViewDefinition } from "@/api"; import { cn } from "@/lib/utils"; -/** Searchable table list with arrow-key navigation, shown in the tab bar's "+" popover. */ +interface Item { + kind: "table" | "view"; + name: string; + columns: number; +} + +/** Searchable list of tables and views with arrow-key navigation, shown in the tab bar's "+" popover. */ export function TablePicker({ tables, + views, openTabs, onSelect, + onSelectView, }: { tables: TableDefinition[]; + views: ViewDefinition[]; openTabs: string[]; onSelect: (name: string) => void; + onSelectView: (name: string) => void; }) { const [search, setSearch] = useState(""); const [highlight, setHighlight] = useState(0); const listRef = useRef(null); const visible = useMemo(() => { + const items: Item[] = [ + ...tables.map((t) => ({ + kind: "table" as const, + name: t.name, + columns: t.columns.length, + })), + ...views.map((v) => ({ + kind: "view" as const, + name: v.name, + columns: v.columns.length, + })), + ]; const q = search.trim().toLowerCase(); - return q ? tables.filter((t) => t.name.toLowerCase().includes(q)) : tables; - }, [tables, search]); + return q ? items.filter((i) => i.name.toLowerCase().includes(q)) : items; + }, [tables, views, search]); useEffect(() => { listRef.current @@ -28,6 +50,9 @@ export function TablePicker({ ?.scrollIntoView({ block: "nearest" }); }, [highlight]); + const choose = (item: Item) => + item.kind === "table" ? onSelect(item.name) : onSelectView(item.name); + function onKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); @@ -36,8 +61,8 @@ export function TablePicker({ e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); } else if (e.key === "Enter") { - const table = visible[highlight]; - if (table) onSelect(table.name); + const item = visible[highlight]; + if (item) choose(item); } } @@ -52,7 +77,7 @@ export function TablePicker({ setHighlight(0); }} onKeyDown={onKeyDown} - placeholder="Open a table…" + placeholder="Open a table or view…" aria-label="Search tables" // biome-ignore lint/a11y/noAutofocus: the picker exists to be typed into autoFocus @@ -60,38 +85,45 @@ export function TablePicker({ />
- {visible.map((table, i) => ( - - ))} + {item.kind === "view" ? ( + + view + + ) : openTabs.includes(item.name) ? ( + open + ) : ( + + {item.columns} cols + + )} + + ); + })} {visible.length === 0 && (

- {tables.length === 0 + {tables.length + views.length === 0 ? "No tables yet" - : `No tables match “${search}”`} + : `Nothing matches “${search}”`}

)}
diff --git a/packages/database-studio/client/src/components/TableView.tsx b/packages/database-studio/client/src/components/TableView.tsx index c7d370f7..c20661fd 100644 --- a/packages/database-studio/client/src/components/TableView.tsx +++ b/packages/database-studio/client/src/components/TableView.tsx @@ -13,12 +13,11 @@ import type { OpenTable } from "@/App"; import { fetchRows, fetchRowWhere, - type Row, type RowsQuery, type RowsResponse, type TableDefinition, } from "@/api"; -import { DataGrid } from "@/components/DataGrid"; +import { DataGrid, type GridColumn } from "@/components/DataGrid"; import { FilterBar } from "@/components/FilterBar"; import { MenuItem, Popover } from "@/components/Popover"; import { RowSheet, type SheetEntry } from "@/components/RowSheet"; @@ -66,10 +65,13 @@ function parseFilters( export function TableView({ table, tables, + ddl, onOpenTable, }: { table: TableDefinition; tables: TableDefinition[]; + /** The table's CREATE statements, for the schema view. */ + ddl: string[]; onOpenTable: OpenTable; }) { const [data, setData] = useState(null); @@ -148,7 +150,13 @@ export function TableView({ useEffect(() => { function onKeyDown(e: KeyboardEvent) { const target = e.target as HTMLElement; - if (target.closest("input, textarea, select, [role=dialog]")) return; + if ( + target.closest( + "input, textarea, select, [contenteditable], [role=dialog]", + ) + ) { + return; + } if (e.key === "r" && !e.metaKey && !e.ctrlKey && !e.altKey) refresh(); } window.addEventListener("keydown", onKeyDown); @@ -208,19 +216,38 @@ export function TableView({ } } - const visibleColumns = table.columns.filter((c) => !hidden.has(c.name)); + const visibleColumns = useMemo( + () => table.columns.filter((c) => !hidden.has(c.name)), + [table, hidden], + ); + const gridColumns = useMemo(() => { + const fks = new Map(table.foreignKeys.map((fk) => [fk.column, fk])); + return visibleColumns.map((c) => { + const fk = fks.get(c.name); + return { + name: c.name, + type: c.type, + primaryKey: table.primaryKey.includes(c.name), + references: fk && `${fk.referencesTable}.${fk.referencesColumn}`, + }; + }); + }, [table, visibleColumns]); + const gridRows = useMemo( + () => data?.rows.map((r) => visibleColumns.map((c) => r[c.name])) ?? [], + [data, visibleColumns], + ); const offset = (page - 1) * limit; function exportPage(format: "csv" | "json") { if (!data) return; const names = visibleColumns.map((c) => c.name); - const rows = data.rows.map((r) => - Object.fromEntries(names.map((n) => [n, r[n]])), - ); const base = `${table.name}-page-${page}`; if (format === "csv") { - download(`${base}.csv`, toCsv(names, rows), "text/csv"); + download(`${base}.csv`, toCsv(names, gridRows), "text/csv"); } else { + const rows = gridRows.map((r) => + Object.fromEntries(names.map((n, i) => [n, r[i]])), + ); download( `${base}.json`, `${JSON.stringify(rows, null, 2)}\n`, @@ -368,7 +395,12 @@ export function TableView({
{view === "schema" ? ( - + ) : ( <> {filtersOpen && ( @@ -412,20 +444,24 @@ export function TableView({ ) : data ? ( { + activeRow={selectedRow} + onOpenRow={(index) => { + const row = data.rows[index]; + if (!row) return; setSelectedRow(index); setSheet([{ table, row }]); }} - onFollowForeignKey={(column, value) => - followForeignKey(table, column, value) - } + onFollowForeignKey={(col, value) => { + const column = gridColumns[col]?.name; + return column + ? followForeignKey(table, column, value) + : Promise.resolve(null); + }} emptyMessage={ filters.length > 0 ? "No rows match these filters." diff --git a/packages/database-studio/client/src/hooks/use-url-state.ts b/packages/database-studio/client/src/hooks/use-url-state.ts index 0086be0b..df2fdcef 100644 --- a/packages/database-studio/client/src/hooks/use-url-state.ts +++ b/packages/database-studio/client/src/hooks/use-url-state.ts @@ -53,3 +53,6 @@ export const TABLE_VIEW_PARAMS = { filters: null, mode: null, } as const; + +/** The `table` param value for the SQL console; not a valid unquoted table name. */ +export const SQL_TAB = ":sql"; diff --git a/packages/database-studio/client/src/lib/format.ts b/packages/database-studio/client/src/lib/format.ts index c49092f8..51fa60c1 100644 --- a/packages/database-studio/client/src/lib/format.ts +++ b/packages/database-studio/client/src/lib/format.ts @@ -1,5 +1,3 @@ -import type { Row } from "@/api"; - /** Plain-text form of a cell, as copied to the clipboard or exported. */ export function toText(value: unknown): string { if (value === null || value === undefined) return ""; @@ -37,10 +35,11 @@ function csvField(value: unknown): string { return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } -export function toCsv(columns: string[], rows: Row[]): string { +/** CSV with a header row; rows are positional so repeated column names keep their own values. */ +export function toCsv(columns: string[], rows: unknown[][]): string { const lines = [columns.map(csvField).join(",")]; for (const row of rows) { - lines.push(columns.map((c) => csvField(row[c])).join(",")); + lines.push(row.map(csvField).join(",")); } return `${lines.join("\r\n")}\r\n`; } diff --git a/packages/database-studio/client/src/lib/sql.test.ts b/packages/database-studio/client/src/lib/sql.test.ts new file mode 100644 index 00000000..be3ad7eb --- /dev/null +++ b/packages/database-studio/client/src/lib/sql.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { quoteIdent, statementAt } from "./sql.ts"; + +describe("statementAt", () => { + const doc = "SELECT 1;\nSELECT ';' AS semi;\n-- note; here\nSELECT 3"; + + test("returns the statement under the cursor", () => { + expect(statementAt(doc, 3)).toBe("SELECT 1"); + expect(statementAt(doc, doc.indexOf("AS semi"))).toBe("SELECT ';' AS semi"); + expect(statementAt(doc, doc.length)).toBe("-- note; here\nSELECT 3"); + }); + + test("a cursor just after a semicolon runs the statement it ends", () => { + expect(statementAt(doc, doc.indexOf(";") + 1)).toBe("SELECT 1"); + }); + + test("a cursor on trailing blank lines runs the last statement", () => { + expect(statementAt("SELECT 1;\n\n", 11)).toBe("SELECT 1"); + }); + + test("a single statement without a semicolon is the whole document", () => { + expect(statementAt(" SELECT * FROM t ", 0)).toBe("SELECT * FROM t"); + }); +}); + +describe("quoteIdent", () => { + test("leaves plain names bare and quotes the rest", () => { + expect(quoteIdent("users")).toBe("users"); + expect(quoteIdent("user roles")).toBe('"user roles"'); + expect(quoteIdent('a"b')).toBe('"a""b"'); + }); +}); diff --git a/packages/database-studio/client/src/lib/sql.ts b/packages/database-studio/client/src/lib/sql.ts new file mode 100644 index 00000000..a4ad6606 --- /dev/null +++ b/packages/database-studio/client/src/lib/sql.ts @@ -0,0 +1,55 @@ +/** + * The statement around `pos`: the text between the semicolons on either side, + * ignoring semicolons inside quotes and comments. Lets Cmd+Enter run the + * statement under the cursor when the editor holds several. + */ +export function statementAt(doc: string, pos: number): string { + const bounds: number[] = []; + let quote: string | null = null; + let comment: "line" | "block" | null = null; + + for (let i = 0; i < doc.length; i++) { + const ch = doc[i]; + const next = doc[i + 1]; + if (comment === "line") { + if (ch === "\n") comment = null; + } else if (comment === "block") { + if (ch === "*" && next === "/") { + comment = null; + i++; + } + } else if (quote) { + if (ch === quote) quote = null; + } else if (ch === "'" || ch === '"' || ch === "`") { + quote = ch; + } else if (ch === "-" && next === "-") { + comment = "line"; + } else if (ch === "/" && next === "*") { + comment = "block"; + } else if (ch === ";") { + bounds.push(i); + } + } + + // A cursor right after a semicolon belongs to the statement it ends. + let start = 0; + let end = doc.length; + for (const b of bounds) { + if (b < pos - 1) { + start = b + 1; + } else { + end = b; + break; + } + } + const statement = doc.slice(start, end).trim(); + // The cursor on a blank line after the last statement means that statement. + if (!statement && start > 0) return statementAt(doc, start - 1); + return statement; +} + +export function quoteIdent(name: string): string { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) + ? name + : `"${name.replace(/"/g, '""')}"`; +} diff --git a/packages/database-studio/client/vite.config.ts b/packages/database-studio/client/vite.config.ts index a5d164ef..15ec4e6c 100644 --- a/packages/database-studio/client/vite.config.ts +++ b/packages/database-studio/client/vite.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ alias: { "@": path.resolve(__dirname, "./src"), }, + // CodeMirror breaks (and the bundle doubles) if two copies of these load. + dedupe: ["@codemirror/state", "@codemirror/view", "@codemirror/language"], }, build: { rollupOptions: { diff --git a/packages/database-studio/package.json b/packages/database-studio/package.json index 46424cdb..4036c92c 100644 --- a/packages/database-studio/package.json +++ b/packages/database-studio/package.json @@ -20,6 +20,13 @@ "@bunny.net/database-rest": "workspace:*" }, "devDependencies": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.13.1", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.14", + "@lezer/highlight": "^1.2.5", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-select": "^2.2.6", diff --git a/packages/database-studio/src/catalog.ts b/packages/database-studio/src/catalog.ts new file mode 100644 index 00000000..399a9b14 --- /dev/null +++ b/packages/database-studio/src/catalog.ts @@ -0,0 +1,63 @@ +import { DEFAULT_EXCLUDE_PATTERNS } from "@bunny.net/database-adapter"; +import type { Database } from "@bunny.net/database-client"; + +export interface ViewDefinition { + name: string; + sql: string | null; + columns: string[]; +} + +export interface Catalog { + /** Each table's CREATE statements: the table first, then its indexes and triggers. */ + ddl: Record; + views: ViewDefinition[]; +} + +type CatalogClient = Pick; + +const quote = (name: string) => `"${name.replace(/"/g, '""')}"`; + +const excluded = (name: string) => + name.startsWith("sqlite_") || + DEFAULT_EXCLUDE_PATTERNS.some((p) => + p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : name === p, + ); + +/** + * What introspection leaves out and the schema view wants: the CREATE + * statements as written (including triggers, which introspection doesn't + * read), and views with their columns. + */ +export async function loadCatalog( + client: CatalogClient, + tableNames: Set, +): Promise { + const { rows } = await client + .prepare( + "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name", + ) + .run(); + + const ddl: Record = {}; + const views: ViewDefinition[] = []; + + for (const row of rows) { + const type = String(row.type); + const name = String(row.name); + const table = String(row.tbl_name); + const sql = row.sql === null ? null : String(row.sql); + + if (type === "view") { + if (excluded(name)) continue; + const info = await client + .prepare(`PRAGMA table_info(${quote(name)})`) + .run(); + views.push({ name, sql, columns: info.rows.map((r) => String(r.name)) }); + } else if (tableNames.has(table) && sql) { + ddl[table] ??= []; + ddl[table].push(sql); + } + } + + return { ddl, views }; +} diff --git a/packages/database-studio/src/query.test.ts b/packages/database-studio/src/query.test.ts new file mode 100644 index 00000000..ae57fc8f --- /dev/null +++ b/packages/database-studio/src/query.test.ts @@ -0,0 +1,118 @@ +import { Database as Sqlite } from "bun:sqlite"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { + DatabaseError, + type RawResult, + type SqlValue, +} from "@bunny.net/database-client"; +import { + MAX_ROWS, + type QueryClient, + QueryRejected, + queryErrorMessage, + runReadOnlyQuery, +} from "./query.ts"; + +let db: Sqlite; + +/** Mirrors the HTTP client: a batch runs on one connection that is gone afterwards, and SQL errors arrive as DatabaseError. */ +const client: QueryClient = { + prepare: (sql: string) => ({ sql }) as never, + batchRaw: async (statements) => { + try { + return statements.map((s): RawResult => { + const query = db.query((s as unknown as { sql: string }).sql); + return { + columns: query.columnNames, + rows: query.values() as SqlValue[][], + rowsAffected: 0, + lastInsertRowid: null, + }; + }); + } catch (err) { + throw new DatabaseError((err as Error).message, { code: "SQLITE_ERROR" }); + } finally { + db.exec("PRAGMA query_only = OFF"); + } + }, +}; + +const count = () => + (db.query("SELECT count(*) AS n FROM users").get() as { n: number }).n; + +beforeEach(() => { + db = new Sqlite(":memory:"); + db.exec(` + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO users (name) VALUES ('Alice'), ('Bob'); + `); +}); + +describe("runReadOnlyQuery", () => { + test("returns positional rows, keeping duplicate column names", async () => { + const result = await runReadOnlyQuery( + client, + "SELECT a.id, b.id FROM users a JOIN users b ON a.id = b.id ORDER BY a.id;", + ); + + expect(result.columns).toEqual(["id", "id"]); + expect(result.rows).toEqual([ + [1, 1], + [2, 2], + ]); + expect(result.truncated).toBe(false); + }); + + test("finds the keyword after leading comments", async () => { + const result = await runReadOnlyQuery( + client, + "-- who's there\n/* all of them */ SELECT name FROM users ORDER BY id", + ); + expect(result.rows).toEqual([["Alice"], ["Bob"]]); + }); + + test("rejects a write statement before it reaches the database", async () => { + const run = runReadOnlyQuery(client, "DELETE FROM users"); + + await expect(run).rejects.toBeInstanceOf(QueryRejected); + expect(count()).toBe(2); + }); + + test("SQLite refuses a write hidden behind an allowed keyword", async () => { + const err = await runReadOnlyQuery( + client, + "WITH doomed AS (SELECT id FROM users) DELETE FROM users WHERE id IN (SELECT id FROM doomed)", + ).catch((e) => e); + + expect(queryErrorMessage(err)).toBe( + "Studio is read-only, so SQLite refused a statement that writes.", + ); + expect(count()).toBe(2); + }); + + test("caps the rows it returns and says so", async () => { + const result = await runReadOnlyQuery( + client, + `WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${MAX_ROWS + 5}) SELECT i FROM n`, + ); + + expect(result.rows).toHaveLength(MAX_ROWS); + expect(result.truncated).toBe(true); + }); +}); + +describe("queryErrorMessage", () => { + test("shows SQL errors, which are about the person's own query", async () => { + const err = await runReadOnlyQuery(client, "SELECT nope FROM users").catch( + (e) => e, + ); + expect(queryErrorMessage(err)).toContain("no such column"); + }); + + test("keeps transport and unknown errors out of the browser", () => { + expect( + queryErrorMessage(new DatabaseError("401 from host", { status: 401 })), + ).toBeNull(); + expect(queryErrorMessage(new Error("boom"))).toBeNull(); + }); +}); diff --git a/packages/database-studio/src/query.ts b/packages/database-studio/src/query.ts new file mode 100644 index 00000000..f198f7fa --- /dev/null +++ b/packages/database-studio/src/query.ts @@ -0,0 +1,91 @@ +import type { Database, SqlValue } from "@bunny.net/database-client"; +import { DatabaseError } from "@bunny.net/database-client"; + +export type QueryClient = Pick; + +export interface QueryResult { + columns: string[]; + /** Positional, so duplicate column names (`a.id`, `b.id`) both survive. */ + rows: SqlValue[][]; + /** True when the query returned more than `MAX_ROWS` rows and the rest were dropped. */ + truncated: boolean; + durationMs: number; +} + +/** Rows sent to the browser per query; the console says when a result was cut. */ +export const MAX_ROWS = 1000; + +const READ_KEYWORDS = new Set([ + "SELECT", + "WITH", + "EXPLAIN", + "PRAGMA", + "VALUES", +]); + +/** A query studio won't run; the message is safe to show as-is. */ +export class QueryRejected extends Error {} + +/** The first keyword after any leading whitespace and comments. */ +export function firstKeyword(sql: string): string | null { + const stripped = sql.replace( + /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/, + "", + ); + return stripped.match(/^[A-Za-z]+/)?.[0]?.toUpperCase() ?? null; +} + +/** + * Run one statement with writes refused at three layers: the keyword + * allowlist rejects obvious writes with a clear message; `PRAGMA query_only` + * makes SQLite refuse any write the statement turns out to make (a CTE that + * ends in DELETE, a PRAGMA that sets a value); and the studio token is + * read-only when the CLI generated it. `batchRaw()` runs the pragma and the + * statement on one connection that closes with the request, so the pragma + * can't leak into other queries. + */ +export async function runReadOnlyQuery( + client: QueryClient, + sql: string, +): Promise { + const statement = sql.trim().replace(/;\s*$/, ""); + if (!statement) throw new QueryRejected("Enter a query to run."); + + const keyword = firstKeyword(statement); + if (!keyword || !READ_KEYWORDS.has(keyword)) { + throw new QueryRejected( + "Studio is read-only: only SELECT, WITH, EXPLAIN, PRAGMA, and VALUES statements run here.", + ); + } + + const start = performance.now(); + const [, result] = await client.batchRaw([ + client.prepare("PRAGMA query_only = ON"), + client.prepare(statement), + ]); + const durationMs = Math.round(performance.now() - start); + const rows = result?.rows ?? []; + + return { + columns: result?.columns ?? [], + rows: rows.slice(0, MAX_ROWS), + truncated: rows.length > MAX_ROWS, + durationMs, + }; +} + +/** + * The message to show the person who typed the query. SQL errors (syntax, + * unknown column, refused write) are about their own query, so they're shown; + * anything else stays generic and goes to the terminal. + */ +export function queryErrorMessage(err: unknown): string | null { + if (err instanceof QueryRejected) return err.message; + if (err instanceof DatabaseError && err.status === undefined) { + if (/readonly|read-only|query_only/i.test(err.message)) { + return "Studio is read-only, so SQLite refused a statement that writes."; + } + return err.message; + } + return null; +} diff --git a/packages/database-studio/src/server.ts b/packages/database-studio/src/server.ts index a254d3de..0e4655f8 100644 --- a/packages/database-studio/src/server.ts +++ b/packages/database-studio/src/server.ts @@ -3,7 +3,9 @@ import { join } from "node:path"; import { createExecutor, introspect } from "@bunny.net/database-adapter"; import type { Database } from "@bunny.net/database-client"; import { createRestHandler, requireAuth } from "@bunny.net/database-rest"; +import { loadCatalog } from "./catalog.ts"; import { assets } from "./client-manifest.ts"; +import { queryErrorMessage, runReadOnlyQuery } from "./query.ts"; export interface StudioOptions { client: Database; @@ -35,13 +37,21 @@ const AUTH_COOKIE = "bunny_studio_auth"; /** Studio-only endpoints live outside `/api`, where every path segment is a table name. */ const SCHEMA_PATH = "/studio-api/schema"; +const QUERY_PATH = "/studio-api/query"; + +/** Same encoding as the REST handler: bigint as a decimal string, bytes as base64. */ +const encodeValue = (_key: string, value: unknown): unknown => { + if (typeof value === "bigint") return value.toString(); + if (value instanceof Uint8Array) return Buffer.from(value).toString("base64"); + return value; +}; const jsonResponse = ( body: unknown, status = 200, headers?: Record, ): Response => - new Response(JSON.stringify(body), { + new Response(JSON.stringify(body, encodeValue), { status, headers: { "Content-Type": "application/json", ...headers }, }); @@ -136,8 +146,45 @@ export async function startStudio(options: StudioOptions): Promise { const tables = Object.values(schema.tables).sort((a, b) => a.name.localeCompare(b.name), ); + const { ddl, views } = await loadCatalog( + client, + new Set(tables.map((t) => t.name)), + ); const handleSchema = requireAuth( - async () => jsonResponse({ label: label ?? null, readOnly: true, tables }), + async () => + jsonResponse({ + label: label ?? null, + readOnly: true, + tables, + ddl, + views, + }), + { token: sessionToken, cookieName: AUTH_COOKIE }, + ); + const handleQuery = requireAuth( + async (req) => { + if (req.method !== "POST") { + return jsonResponse({ message: "Method not allowed" }, 405, { + Allow: "POST", + }); + } + const body = (await req.json().catch(() => null)) as { + sql?: unknown; + } | null; + if (typeof body?.sql !== "string") { + return jsonResponse({ message: "Expected { sql: string }" }, 400); + } + try { + return jsonResponse(await runReadOnlyQuery(client, body.sql)); + } catch (err) { + const message = queryErrorMessage(err); + if (message) return jsonResponse({ message }, 400); + logger.error( + `Query error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, + ); + return jsonResponse({ message: "Internal error" }, 500); + } + }, { token: sessionToken, cookieName: AUTH_COOKIE }, ); @@ -160,6 +207,9 @@ export async function startStudio(options: StudioOptions): Promise { if (pathname === SCHEMA_PATH) { return await handleSchema(req); } + if (pathname === QUERY_PATH) { + return await handleQuery(req); + } // API routes - delegate to REST handler if (pathname.startsWith("/api")) { From 937f270c5d119d59ebcdaf06df910ba38667b1e7 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 14:39:48 +0000 Subject: [PATCH 4/7] fix(db): make studio work with read-only libSQL tokens --- AGENTS.md | 3 +- packages/cli/README.md | 2 +- packages/cli/src/commands/db/studio.ts | 22 +- .../database-adapter/src/introspect.test.ts | 23 ++ packages/database-adapter/src/introspect.ts | 12 +- packages/database-studio/client/src/api.ts | 2 + .../client/src/components/SqlConsole.tsx | 10 +- packages/database-studio/src/query.test.ts | 148 +++++++++---- packages/database-studio/src/query.ts | 196 +++++++++++++++--- packages/database-studio/src/server.ts | 8 + 10 files changed, 332 insertions(+), 94 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 814e3593..8f31dcf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -518,7 +518,8 @@ 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 and runs each statement in a `batchRaw()` after `PRAGMA query_only = ON`, so SQLite itself refuses a write hidden behind an allowed keyword (`WITH … DELETE`). Adding writes means lifting each layer deliberately, not just one. +- **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/ (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/`. - **Full-scan dot-commands** (`.count`, `.size`, `.dump`) require confirmation via `confirmReadQuota()`, since reads count against the quota. diff --git a/packages/cli/README.md b/packages/cli/README.md index 1830a968..a1aa95b2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -377,7 +377,7 @@ Open a read-only database explorer in your browser. Spins up a local server, gen 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 (⌘K / Ctrl K 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 (⌘C copies, Enter 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. ⌘Enter / Ctrl Enter runs the selection or the statement under the cursor. Only `SELECT`, `WITH`, `EXPLAIN`, `PRAGMA`, and `VALUES` run, and SQLite is put in `query_only` mode so anything that tries to write is refused. 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. +The **SQL** tab is a query console with schema-aware autocomplete and query history. ⌘Enter / Ctrl Enter 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) diff --git a/packages/cli/src/commands/db/studio.ts b/packages/cli/src/commands/db/studio.ts index f8c12f90..a50d5fd2 100644 --- a/packages/cli/src/commands/db/studio.ts +++ b/packages/cli/src/commands/db/studio.ts @@ -108,15 +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, databaseId } = await resolveCredentials({ - url: urlArg, - token: tokenArg, - databaseId: databaseIdArg, - profile, - apiKey, - verbose, - authorization: "read-only", - }); + const { url, token, databaseId, tokenGenerated } = await resolveCredentials( + { + url: urlArg, + token: tokenArg, + databaseId: databaseIdArg, + profile, + apiKey, + verbose, + authorization: "read-only", + }, + ); const client = connect({ url, @@ -128,6 +130,8 @@ export const dbStudioCommand = defineCommand<{ 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, dev, diff --git a/packages/database-adapter/src/introspect.test.ts b/packages/database-adapter/src/introspect.test.ts index 47ddc4db..89c094ed 100644 --- a/packages/database-adapter/src/introspect.test.ts +++ b/packages/database-adapter/src/introspect.test.ts @@ -134,6 +134,29 @@ describe("introspect", () => { 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"); diff --git a/packages/database-adapter/src/introspect.ts b/packages/database-adapter/src/introspect.ts index f6c3d04b..74317541 100644 --- a/packages/database-adapter/src/introspect.ts +++ b/packages/database-adapter/src/introspect.ts @@ -79,10 +79,14 @@ const getForeignKeys = async ( client: AdapterClient, tableName: string, ): Promise => { - 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, diff --git a/packages/database-studio/client/src/api.ts b/packages/database-studio/client/src/api.ts index 0788b7d5..89112b33 100644 --- a/packages/database-studio/client/src/api.ts +++ b/packages/database-studio/client/src/api.ts @@ -22,6 +22,8 @@ export interface StudioSchema { /** Which database is open, e.g. `db_… (host)`. */ label: string | null; readOnly: boolean; + /** The database token itself is read-only (studio created it), not just studio's checks. */ + tokenReadOnly: boolean; tables: TableDefinition[]; /** Each table's CREATE statements: the table, then its indexes and triggers. */ ddl: Record; diff --git a/packages/database-studio/client/src/components/SqlConsole.tsx b/packages/database-studio/client/src/components/SqlConsole.tsx index ec0af38f..86683705 100644 --- a/packages/database-studio/client/src/components/SqlConsole.tsx +++ b/packages/database-studio/client/src/components/SqlConsole.tsx @@ -226,10 +226,16 @@ export function SqlConsole({ - SELECT, WITH, EXPLAIN, PRAGMA, VALUES + {schema.tokenReadOnly + ? "Read-only token · SELECT, WITH, EXPLAIN, PRAGMA, VALUES" + : "Your token · writes blocked by studio · SELECT, WITH, EXPLAIN, PRAGMA, VALUES"} diff --git a/packages/database-studio/src/query.test.ts b/packages/database-studio/src/query.test.ts index ae57fc8f..a84f1656 100644 --- a/packages/database-studio/src/query.test.ts +++ b/packages/database-studio/src/query.test.ts @@ -1,46 +1,46 @@ import { Database as Sqlite } from "bun:sqlite"; import { beforeEach, describe, expect, test } from "bun:test"; -import { - DatabaseError, - type RawResult, - type SqlValue, -} from "@bunny.net/database-client"; +import { DatabaseError, type SqlValue } from "@bunny.net/database-client"; import { MAX_ROWS, type QueryClient, QueryRejected, queryErrorMessage, + rejectionReason, runReadOnlyQuery, } from "./query.ts"; let db: Sqlite; +let sent: string[]; -/** Mirrors the HTTP client: a batch runs on one connection that is gone afterwards, and SQL errors arrive as DatabaseError. */ +/** Mirrors the HTTP client's runRaw(), recording what reaches the database. */ const client: QueryClient = { - prepare: (sql: string) => ({ sql }) as never, - batchRaw: async (statements) => { - try { - return statements.map((s): RawResult => { - const query = db.query((s as unknown as { sql: string }).sql); - return { - columns: query.columnNames, - rows: query.values() as SqlValue[][], - rowsAffected: 0, - lastInsertRowid: null, - }; - }); - } catch (err) { - throw new DatabaseError((err as Error).message, { code: "SQLITE_ERROR" }); - } finally { - db.exec("PRAGMA query_only = OFF"); - } - }, + prepare: (sql: string) => + ({ + runRaw: async () => { + sent.push(sql); + try { + const query = db.query(sql); + return { + columns: query.columnNames, + rows: query.values() as SqlValue[][], + rowsAffected: 0, + lastInsertRowid: null, + }; + } catch (err) { + throw new DatabaseError((err as Error).message, { + code: "SQLITE_ERROR", + }); + } + }, + }) as never, }; const count = () => (db.query("SELECT count(*) AS n FROM users").get() as { n: number }).n; beforeEach(() => { + sent = []; db = new Sqlite(":memory:"); db.exec(` CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); @@ -63,30 +63,33 @@ describe("runReadOnlyQuery", () => { expect(result.truncated).toBe(false); }); - test("finds the keyword after leading comments", async () => { - const result = await runReadOnlyQuery( - client, - "-- who's there\n/* all of them */ SELECT name FROM users ORDER BY id", - ); - expect(result.rows).toEqual([["Alice"], ["Bob"]]); + test("sends only the statement itself, nothing libSQL would reject", async () => { + await runReadOnlyQuery(client, "SELECT 1"); + expect(sent).toEqual(["SELECT 1"]); }); - test("rejects a write statement before it reaches the database", async () => { - const run = runReadOnlyQuery(client, "DELETE FROM users"); + test("runs read-only PRAGMAs in their SELECT form, which a read-only token allows", async () => { + const result = await runReadOnlyQuery(client, "PRAGMA table_info('users')"); - await expect(run).rejects.toBeInstanceOf(QueryRejected); - expect(count()).toBe(2); + expect(sent).toEqual(["SELECT * FROM pragma_table_info('users')"]); + expect(result.rows.map((r) => r[1])).toEqual(["id", "name"]); + + await runReadOnlyQuery(client, "PRAGMA user_version;"); + expect(sent[1]).toBe("SELECT * FROM pragma_user_version()"); }); - test("SQLite refuses a write hidden behind an allowed keyword", async () => { - const err = await runReadOnlyQuery( - client, + test("rejects writes before they reach the database", async () => { + for (const sql of [ + "DELETE FROM users", "WITH doomed AS (SELECT id FROM users) DELETE FROM users WHERE id IN (SELECT id FROM doomed)", - ).catch((e) => e); - - expect(queryErrorMessage(err)).toBe( - "Studio is read-only, so SQLite refused a statement that writes.", - ); + "-- looks harmless\nWITH x AS (SELECT 1) UPDATE users SET name = 'x'", + "WITH x AS (SELECT 1) REPLACE INTO users (id, name) VALUES (1, 'x')", + ]) { + await expect(runReadOnlyQuery(client, sql)).rejects.toBeInstanceOf( + QueryRejected, + ); + } + expect(sent).toEqual([]); expect(count()).toBe(2); }); @@ -101,6 +104,46 @@ describe("runReadOnlyQuery", () => { }); }); +describe("rejectionReason", () => { + test("ignores write words inside strings, identifiers, comments, and replace()", () => { + for (const sql of [ + "SELECT 'please delete me' AS note", + 'SELECT "update" FROM t', + "SELECT [drop] FROM t", + "SELECT 1 /* insert later */", + "SELECT replace(name, 'a', 'b') FROM users", + "SELECT deleted_at, created_at FROM users", + "-- a comment first\nSELECT 1", + ]) { + expect(rejectionReason(sql)).toBeNull(); + } + }); + + test("allows PRAGMAs that only read", () => { + for (const sql of [ + "PRAGMA table_info(users)", + "PRAGMA main.index_list('users')", + "PRAGMA foreign_key_list(users)", + "PRAGMA user_version", + "PRAGMA compile_options", + ]) { + expect(rejectionReason(sql)).toBeNull(); + } + }); + + test("rejects PRAGMAs that set something", () => { + for (const sql of [ + "PRAGMA user_version = 5", + "PRAGMA user_version(5)", + "PRAGMA query_only = ON", + "PRAGMA writable_schema = 1", + "PRAGMA table_info = users", + ]) { + expect(rejectionReason(sql)).not.toBeNull(); + } + }); +}); + describe("queryErrorMessage", () => { test("shows SQL errors, which are about the person's own query", async () => { const err = await runReadOnlyQuery(client, "SELECT nope FROM users").catch( @@ -109,9 +152,26 @@ describe("queryErrorMessage", () => { expect(queryErrorMessage(err)).toContain("no such column"); }); - test("keeps transport and unknown errors out of the browser", () => { + test("explains a statement the read-only token may not run", () => { + for (const err of [ + new DatabaseError("Forbidden", { status: 403 }), + new DatabaseError("Not authorized to execute query", { + code: "SQLITE_AUTH", + }), + ]) { + expect(queryErrorMessage(err)).toContain("read-only"); + } + }); + + test("explains a rejected token", () => { + expect( + queryErrorMessage(new DatabaseError("Unauthorized", { status: 401 })), + ).toContain("restart `bunny db studio`"); + }); + + test("keeps other transport and unknown errors out of the browser", () => { expect( - queryErrorMessage(new DatabaseError("401 from host", { status: 401 })), + queryErrorMessage(new DatabaseError("upstream 502", { status: 502 })), ).toBeNull(); expect(queryErrorMessage(new Error("boom"))).toBeNull(); }); diff --git a/packages/database-studio/src/query.ts b/packages/database-studio/src/query.ts index f198f7fa..cbfb4d7d 100644 --- a/packages/database-studio/src/query.ts +++ b/packages/database-studio/src/query.ts @@ -1,7 +1,7 @@ import type { Database, SqlValue } from "@bunny.net/database-client"; import { DatabaseError } from "@bunny.net/database-client"; -export type QueryClient = Pick; +export type QueryClient = Pick; export interface QueryResult { columns: string[]; @@ -23,26 +23,149 @@ const READ_KEYWORDS = new Set([ "VALUES", ]); +/** Keywords that only appear in statements that change something. */ +const WRITE_KEYWORDS = new Set([ + "INSERT", + "UPDATE", + "DELETE", + "REPLACE", + "UPSERT", + "CREATE", + "DROP", + "ALTER", + "ATTACH", + "DETACH", + "VACUUM", + "REINDEX", + "ANALYZE", + "BEGIN", + "COMMIT", + "ROLLBACK", + "SAVEPOINT", + "RELEASE", +]); + +/** PRAGMAs that read schema and may name a table or index: `PRAGMA table_info(users)`. */ +const SCHEMA_PRAGMAS = new Set([ + "table_info", + "table_xinfo", + "table_list", + "index_list", + "index_info", + "index_xinfo", + "foreign_key_list", + "foreign_key_check", + "integrity_check", + "quick_check", +]); + +/** PRAGMAs that report a setting; with an argument or `=` they'd change it, so they must stand alone. */ +const STATUS_PRAGMAS = new Set([ + "compile_options", + "database_list", + "function_list", + "module_list", + "pragma_list", + "collation_list", + "user_version", + "schema_version", + "application_id", + "page_count", + "page_size", + "freelist_count", + "data_version", + "encoding", + "journal_mode", + "foreign_keys", +]); + /** A query studio won't run; the message is safe to show as-is. */ export class QueryRejected extends Error {} +/** + * The SQL with comments, string literals, and quoted identifiers blanked + * out, so keyword checks only see the statement's own words. + */ +function codeOnly(sql: string): string { + return sql.replace( + /--[^\n]*|\/\*[\s\S]*?\*\/|'(?:[^']|'')*'|"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]/g, + " ", + ); +} + /** The first keyword after any leading whitespace and comments. */ export function firstKeyword(sql: string): string | null { - const stripped = sql.replace( - /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/, - "", + return ( + codeOnly(sql) + .trim() + .match(/^[A-Za-z]+/)?.[0] + ?.toUpperCase() ?? null + ); +} + +/** Why studio won't run this statement, or null when it may. */ +export function rejectionReason(statement: string): string | null { + const code = codeOnly(statement); + const keyword = firstKeyword(statement); + + if (!keyword || !READ_KEYWORDS.has(keyword)) { + return "Studio is read-only: only SELECT, WITH, EXPLAIN, PRAGMA, and VALUES statements run here."; + } + + if (keyword === "PRAGMA") { + const match = code.match(/^\s*PRAGMA\s+(?:\w+\s*\.\s*)?(\w+)\s*(.*)$/is); + const name = match?.[1]?.toLowerCase() ?? ""; + const rest = (match?.[2] ?? "").trim(); + const argOk = /^(\(\s*\)|\([^=()]*\))?$/.test(rest); + const allowed = + (SCHEMA_PRAGMAS.has(name) && argOk) || + (STATUS_PRAGMAS.has(name) && rest === ""); + return allowed + ? null + : `Studio only runs PRAGMAs that read, such as table_info(name) or user_version on its own. PRAGMA ${name || "?"} as written could change a setting.`; + } + + // `REPLACE(...)` is the string function; `REPLACE INTO` is a write. + for (const match of code.matchAll(/[A-Za-z_]+/g)) { + const word = match[0].toUpperCase(); + if (!WRITE_KEYWORDS.has(word)) continue; + const after = code.slice(match.index + word.length); + if (word === "REPLACE" && /^\s*\(/.test(after)) { + continue; + } + return `Studio is read-only, and this statement contains ${word}, which writes.`; + } + + return null; +} + +/** + * `PRAGMA name(arg)` as `SELECT * FROM pragma_name('arg')`. libSQL servers + * classify some read-only PRAGMAs (foreign_key_list, user_version) as + * writes, so a read-only token can't run them, but the table-valued form is a + * SELECT. Only called for PRAGMAs `rejectionReason()` already allowed. + */ +export function pragmaAsSelect(statement: string): string | null { + const match = codeOnly(statement).match( + /^\s*PRAGMA\s+(?:\w+\s*\.\s*)?(\w+)\s*(?:\(\s*([^()]*?)\s*\))?\s*$/i, ); - return stripped.match(/^[A-Za-z]+/)?.[0]?.toUpperCase() ?? null; + if (!match) return null; + const name = match[1]?.toLowerCase(); + // The argument was blanked out if it was quoted, so read it from the original. + const raw = statement.match(/\(\s*([^()]*?)\s*\)\s*;?\s*$/)?.[1] ?? ""; + const arg = raw + .replace(/^(['"`])(.*)\1$/s, "$2") + .replace(/^\[(.*)\]$/s, "$1"); + return `SELECT * FROM pragma_${name}(${arg ? `'${arg.replace(/'/g, "''")}'` : ""})`; } /** - * Run one statement with writes refused at three layers: the keyword - * allowlist rejects obvious writes with a clear message; `PRAGMA query_only` - * makes SQLite refuse any write the statement turns out to make (a CTE that - * ends in DELETE, a PRAGMA that sets a value); and the studio token is - * read-only when the CLI generated it. `batchRaw()` runs the pragma and the - * statement on one connection that closes with the request, so the pragma - * can't leak into other queries. + * Run one statement from the SQL console. Writes are stopped at two layers: + * studio rejects statements that contain write keywords, and a libSQL + * server refuses anything its parser classifies as a write when the token is + * read-only, which it is whenever studio created the token. (SQLite's + * `PRAGMA query_only` would be a neater guard, but libSQL servers reject + * that statement outright.) */ export async function runReadOnlyQuery( client: QueryClient, @@ -51,41 +174,48 @@ export async function runReadOnlyQuery( const statement = sql.trim().replace(/;\s*$/, ""); if (!statement) throw new QueryRejected("Enter a query to run."); - const keyword = firstKeyword(statement); - if (!keyword || !READ_KEYWORDS.has(keyword)) { - throw new QueryRejected( - "Studio is read-only: only SELECT, WITH, EXPLAIN, PRAGMA, and VALUES statements run here.", - ); - } + const reason = rejectionReason(statement); + if (reason) throw new QueryRejected(reason); + const runnable = + firstKeyword(statement) === "PRAGMA" + ? (pragmaAsSelect(statement) ?? statement) + : statement; const start = performance.now(); - const [, result] = await client.batchRaw([ - client.prepare("PRAGMA query_only = ON"), - client.prepare(statement), - ]); + const result = await client.prepare(runnable).runRaw(); const durationMs = Math.round(performance.now() - start); - const rows = result?.rows ?? []; return { - columns: result?.columns ?? [], - rows: rows.slice(0, MAX_ROWS), - truncated: rows.length > MAX_ROWS, + columns: result.columns, + rows: result.rows.slice(0, MAX_ROWS), + truncated: result.rows.length > MAX_ROWS, durationMs, }; } +const AUTH_ERROR = + /not authori[sz]ed|unauthori[sz]ed|forbidden|permission|read.?only|readonly/i; + /** * The message to show the person who typed the query. SQL errors (syntax, - * unknown column, refused write) are about their own query, so they're shown; - * anything else stays generic and goes to the terminal. + * unknown column) are about their own query, so they're shown; scope and + * token failures get an explanation; anything else stays generic and goes to + * the terminal. */ export function queryErrorMessage(err: unknown): string | null { if (err instanceof QueryRejected) return err.message; - if (err instanceof DatabaseError && err.status === undefined) { - if (/readonly|read-only|query_only/i.test(err.message)) { - return "Studio is read-only, so SQLite refused a statement that writes."; - } - return err.message; + if (!(err instanceof DatabaseError)) return null; + + if (err.status === 401) { + return "The database rejected studio's token. Generated tokens expire after a while; restart `bunny db studio` to get a new one."; + } + if ( + err.status === 403 || + AUTH_ERROR.test(err.message) || + AUTH_ERROR.test(err.code ?? "") + ) { + return "The database refused this statement: studio's token is read-only, and the server treats the statement as a write."; } + if (err.status === undefined) return err.message; return null; } diff --git a/packages/database-studio/src/server.ts b/packages/database-studio/src/server.ts index 0e4655f8..dde3d9b0 100644 --- a/packages/database-studio/src/server.ts +++ b/packages/database-studio/src/server.ts @@ -11,6 +11,12 @@ export interface StudioOptions { client: Database; /** Shown in the studio header so it's clear which database is open (e.g. its ID and host). */ label?: string; + /** + * True when the connection's token is known to be read-only (the CLI + * created it). Otherwise the SQL console relies on studio's own statement + * checks, and says so. + */ + tokenReadOnly?: boolean; port?: number; open?: boolean; dev?: boolean; @@ -116,6 +122,7 @@ export async function startStudio(options: StudioOptions): Promise { const { client, label, + tokenReadOnly = false, port = 4488, open = true, dev = false, @@ -155,6 +162,7 @@ export async function startStudio(options: StudioOptions): Promise { jsonResponse({ label: label ?? null, readOnly: true, + tokenReadOnly, tables, ddl, views, From d147680d95285dade7a9db9e23e06f3ecbe57997 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 14:43:56 +0000 Subject: [PATCH 5/7] fix(db): address studio review findings on views, filters and the SQL console --- packages/database-rest/README.md | 2 +- packages/database-rest/src/handler.test.ts | 29 ++++++++++ packages/database-rest/src/parser.ts | 41 +++++++++----- packages/database-studio/client/src/api.ts | 7 ++- .../client/src/lib/filters.test.ts | 31 +++++++++++ .../database-studio/client/src/lib/filters.ts | 20 +++++-- .../client/src/lib/sql.test.ts | 4 +- .../database-studio/client/src/lib/sql.ts | 5 +- packages/database-studio/src/catalog.test.ts | 25 +++++++++ packages/database-studio/src/catalog.ts | 23 ++++++-- packages/database-studio/src/query.test.ts | 30 ++++++++++ packages/database-studio/src/query.ts | 55 +++++++++++-------- 12 files changed, 219 insertions(+), 53 deletions(-) create mode 100644 packages/database-studio/client/src/lib/filters.test.ts create mode 100644 packages/database-studio/src/catalog.test.ts diff --git a/packages/database-rest/README.md b/packages/database-rest/README.md index 61430e2e..cf8f01ba 100644 --- a/packages/database-rest/README.md +++ b/packages/database-rest/README.md @@ -188,7 +188,7 @@ 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 malformed `or` group returns `400` rather than being dropped, since ignoring part of it would match more rows. +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 diff --git a/packages/database-rest/src/handler.test.ts b/packages/database-rest/src/handler.test.ts index bcbb21c5..6ae5c340 100644 --- a/packages/database-rest/src/handler.test.ts +++ b/packages/database-rest/src/handler.test.ts @@ -310,6 +310,35 @@ describe("value encoding", () => { expect(body.data[0].data).toBe("AQL/"); }); + test("matches number-like text as written, and still compares numbers", async () => { + const codes = new Database(":memory:"); + codes.exec( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users VALUES (7, '007'), (8, '1e3'), (9, 'x')", + ); + const h = createRestHandler(createTestExecutor(codes), schema); + const names = async (path: string) => + (await jsonBody(await h(req("GET", path)))).data.map( + (u: { name: string }) => u.name, + ); + + expect(await names("/users?name=eq.007")).toEqual(["007"]); + expect(await names("/users?name=eq.1e3")).toEqual(["1e3"]); + expect(await names("/users?id=eq.7")).toEqual(["007"]); + expect( + await names( + `/users?or=${encodeURIComponent('(name.eq."007",name.eq.x)')}`, + ), + ).toEqual(["007", "x"]); + codes.close(); + }); + + test("refuses a filter it can't parse instead of dropping it", async () => { + for (const path of ["/users?name=not.bogus", "/users?name=bogus.x"]) { + const res = await handler(req("GET", path)); + expect(res.status).toBe(400); + } + }); + test("filters on integers beyond 2^53 without rounding them", async () => { const big = new Database(":memory:"); big.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"); diff --git a/packages/database-rest/src/parser.ts b/packages/database-rest/src/parser.ts index 2549308d..1f796f75 100644 --- a/packages/database-rest/src/parser.ts +++ b/packages/database-rest/src/parser.ts @@ -132,15 +132,21 @@ function parseIsValue(raw: string): boolean | null { } /** - * Numeric strings become numbers, except integers beyond 2^53, which a - * number can't hold exactly; those stay strings, and SQLite's INTEGER - * affinity still compares them as integers. + * A value becomes a number only when it's written the way a number prints + * (`18`, `-2.5`): then it compares correctly against any column, and SQLite's + * TEXT affinity still turns it back into `'18'` for a TEXT column. Anything + * else stays a string so it matches as written: `007` and `1e3` would + * otherwise become 7 and 1000 and miss TEXT values, and integers beyond 2^53 + * would lose precision (INTEGER affinity still compares those strings as + * integers). */ export function parseValue(raw: string): string | number { const num = Number(raw); - if (Number.isNaN(num) || raw.trim() === "") return raw; - if (Number.isInteger(num) && !Number.isSafeInteger(num)) return raw; - return num; + return Number.isFinite(num) && + Number.isSafeInteger(Math.trunc(num)) && + String(num) === raw + ? num + : raw; } /** @@ -187,14 +193,19 @@ export function parseOrGroup(raw: string): FilterGroup | null { if (dotIndex <= 0) return null; const column = part.substring(0, dotIndex).trim(); let rest = part.substring(dotIndex + 1); - // Unquote `op."value"` (and `not.op."value"`) so quoted values compare as written. - rest = rest.replace( - /^((?:not\.)?[a-z]+\.)"(.*)"$/s, - (_, prefix: string, value: string) => - prefix + value.replace(/\\(.)/gs, "$1"), - ); + // `op."value"` (and `not.op."value"`): the quotes make it a literal string. + const quoted = rest.match(/^((?:not\.)?[a-z]+\.)"(.*)"$/s); + const literal = quoted?.[2]?.replace(/\\(.)/gs, "$1"); + if (quoted?.[1]) rest = `${quoted[1]}${literal}`; const condition = parseFilterValue(column, rest); if (!condition || !rest.includes(".")) return null; + if ( + literal !== undefined && + condition.operator !== "in" && + condition.operator !== "is" + ) { + condition.value = literal; + } conditions.push(condition); } return { or: conditions }; @@ -248,9 +259,11 @@ export function parseQueryParams(url: URL): ParsedQuery { } const filter = parseFilterValue(key, value); - if (filter) { - filters.push(filter); + if (!filter) { + // Dropping a filter would widen the match, so refuse it instead. + throw new QueryParseError(`Invalid filter: ${key}=${value}`); } + filters.push(filter); } return { diff --git a/packages/database-studio/client/src/api.ts b/packages/database-studio/client/src/api.ts index 89112b33..dde74ba3 100644 --- a/packages/database-studio/client/src/api.ts +++ b/packages/database-studio/client/src/api.ts @@ -117,7 +117,12 @@ export async function fetchRowWhere( value: unknown, ): Promise { const params = new URLSearchParams({ limit: "1" }); - params.set(column, `eq.${String(value)}`); + for (const [key, v] of filterParams( + [{ column, operator: "eq", value: String(value) }], + "and", + )) { + params.append(key, v); + } const res = await request(`/api/${encodeURIComponent(table)}?${params}`); const body = (await res.json()) as { data: Row[] }; return body.data[0] ?? null; diff --git a/packages/database-studio/client/src/lib/filters.test.ts b/packages/database-studio/client/src/lib/filters.test.ts new file mode 100644 index 00000000..e98d8288 --- /dev/null +++ b/packages/database-studio/client/src/lib/filters.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { filterParams } from "./filters.ts"; + +describe("filterParams", () => { + test("puts a filter on a column named like a paging parameter in a group", () => { + expect( + filterParams( + [ + { column: "limit", operator: "eq", value: "5" }, + { column: "name", operator: "eq", value: "Ann" }, + ], + "and", + ), + ).toEqual([ + ["or", "(limit.eq.5)"], + ["name", "eq.Ann"], + ]); + }); + + test("combines OR filters into one group, quoting values that need it", () => { + expect( + filterParams( + [ + { column: "name", operator: "eq", value: "Smith, John" }, + { column: "age", operator: "null", value: "" }, + ], + "or", + ), + ).toEqual([["or", '(name.eq."Smith, John",age.is.null)']]); + }); +}); diff --git a/packages/database-studio/client/src/lib/filters.ts b/packages/database-studio/client/src/lib/filters.ts index 5eddf02b..4ccd2dee 100644 --- a/packages/database-studio/client/src/lib/filters.ts +++ b/packages/database-studio/client/src/lib/filters.ts @@ -75,6 +75,12 @@ function quoteForGroup(encoded: string): string { return `${op}"${value.replace(/[\\"]/g, "\\$&")}"`; } +/** Query parameters the REST handler reads as paging or options, not as column filters. */ +const RESERVED_PARAMS = new Set(["select", "order", "limit", "offset", "or"]); + +const groupPart = (f: FilterCondition) => + `${f.column}.${quoteForGroup(OPERATORS[f.operator].encode(f.value))}`; + /** Query parameters for the REST handler: one per filter, or a single `or=(...)` group. */ export function filterParams( filters: FilterCondition[], @@ -82,11 +88,13 @@ export function filterParams( ): [string, string][] { const ready = filters.filter(isComplete); if (mode === "or" && ready.length > 1) { - const parts = ready.map( - (f) => - `${f.column}.${quoteForGroup(OPERATORS[f.operator].encode(f.value))}`, - ); - return [["or", `(${parts.join(",")})`]]; + return [["or", `(${ready.map(groupPart).join(",")})`]]; } - return ready.map((f) => [f.column, OPERATORS[f.operator].encode(f.value)]); + return ready.map((f): [string, string] => + // A column named `limit` or `order` would collide with the paging + // parameter, so its filter goes in a one-condition group instead. + RESERVED_PARAMS.has(f.column) + ? ["or", `(${groupPart(f)})`] + : [f.column, OPERATORS[f.operator].encode(f.value)], + ); } diff --git a/packages/database-studio/client/src/lib/sql.test.ts b/packages/database-studio/client/src/lib/sql.test.ts index be3ad7eb..85e0c5ce 100644 --- a/packages/database-studio/client/src/lib/sql.test.ts +++ b/packages/database-studio/client/src/lib/sql.test.ts @@ -24,8 +24,8 @@ describe("statementAt", () => { }); describe("quoteIdent", () => { - test("leaves plain names bare and quotes the rest", () => { - expect(quoteIdent("users")).toBe("users"); + test("quotes every name, so keywords work as table names", () => { + expect(quoteIdent("order")).toBe('"order"'); expect(quoteIdent("user roles")).toBe('"user roles"'); expect(quoteIdent('a"b')).toBe('"a""b"'); }); diff --git a/packages/database-studio/client/src/lib/sql.ts b/packages/database-studio/client/src/lib/sql.ts index a4ad6606..64fa3218 100644 --- a/packages/database-studio/client/src/lib/sql.ts +++ b/packages/database-studio/client/src/lib/sql.ts @@ -48,8 +48,7 @@ export function statementAt(doc: string, pos: number): string { return statement; } +/** Always quoted: a bare name can be a keyword (`order`, `group`). */ export function quoteIdent(name: string): string { - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) - ? name - : `"${name.replace(/"/g, '""')}"`; + return `"${name.replace(/"/g, '""')}"`; } diff --git a/packages/database-studio/src/catalog.test.ts b/packages/database-studio/src/catalog.test.ts new file mode 100644 index 00000000..5d02b4ad --- /dev/null +++ b/packages/database-studio/src/catalog.test.ts @@ -0,0 +1,25 @@ +import { Database as Sqlite } from "bun:sqlite"; +import { expect, test } from "bun:test"; +import { sqliteClient } from "@bunny.net/database-adapter/sqlite"; +import type { Database } from "@bunny.net/database-client"; +import { loadCatalog } from "./catalog.ts"; + +test("a view over a dropped table doesn't stop the catalog loading", async () => { + const db = new Sqlite(":memory:"); + db.exec(` + CREATE TABLE a (x INTEGER); + CREATE TABLE b (y INTEGER); + CREATE VIEW stale AS SELECT x FROM a; + CREATE VIEW fine AS SELECT y FROM b; + DROP TABLE a; + `); + const client = sqliteClient(db) as unknown as Pick; + + const { views, ddl } = await loadCatalog(client, new Set(["b"])); + + expect(views.map((v) => [v.name, v.columns])).toEqual([ + ["fine", ["y"]], + ["stale", []], + ]); + expect(ddl.b).toEqual(["CREATE TABLE b (y INTEGER)"]); +}); diff --git a/packages/database-studio/src/catalog.ts b/packages/database-studio/src/catalog.ts index 399a9b14..f382f76c 100644 --- a/packages/database-studio/src/catalog.ts +++ b/packages/database-studio/src/catalog.ts @@ -23,6 +23,24 @@ const excluded = (name: string) => p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : name === p, ); +/** + * A view's columns, or none when SQLite can't resolve it (a view over a + * dropped table): one stale view must not stop studio from starting. + */ +async function viewColumns( + client: CatalogClient, + name: string, +): Promise { + try { + const info = await client + .prepare(`PRAGMA table_info(${quote(name)})`) + .run(); + return info.rows.map((r) => String(r.name)); + } catch { + return []; + } +} + /** * What introspection leaves out and the schema view wants: the CREATE * statements as written (including triggers, which introspection doesn't @@ -49,10 +67,7 @@ export async function loadCatalog( if (type === "view") { if (excluded(name)) continue; - const info = await client - .prepare(`PRAGMA table_info(${quote(name)})`) - .run(); - views.push({ name, sql, columns: info.rows.map((r) => String(r.name)) }); + views.push({ name, sql, columns: await viewColumns(client, name) }); } else if (tableNames.has(table) && sql) { ddl[table] ??= []; ddl[table].push(sql); diff --git a/packages/database-studio/src/query.test.ts b/packages/database-studio/src/query.test.ts index a84f1656..83aeab34 100644 --- a/packages/database-studio/src/query.test.ts +++ b/packages/database-studio/src/query.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { DatabaseError, type SqlValue } from "@bunny.net/database-client"; import { MAX_ROWS, + pragmaAsSelect, type QueryClient, QueryRejected, queryErrorMessage, @@ -104,6 +105,29 @@ describe("runReadOnlyQuery", () => { }); }); +describe("pragmaAsSelect", () => { + test("passes the argument as a plain string and keeps the schema", () => { + expect(pragmaAsSelect("PRAGMA table_info(users)")).toBe( + "SELECT * FROM pragma_table_info('users')", + ); + expect(pragmaAsSelect('PRAGMA table_info("my""t")')).toBe( + `SELECT * FROM pragma_table_info('my"t')`, + ); + expect(pragmaAsSelect("PRAGMA table_info('o''b')")).toBe( + "SELECT * FROM pragma_table_info('o''b')", + ); + expect(pragmaAsSelect('PRAGMA table_info("a(b)");')).toBe( + "SELECT * FROM pragma_table_info('a(b)')", + ); + expect(pragmaAsSelect("PRAGMA aux.table_info(t)")).toBe( + "SELECT * FROM pragma_table_info('t', 'aux')", + ); + expect(pragmaAsSelect("PRAGMA aux.user_version")).toBe( + "SELECT * FROM pragma_user_version('aux')", + ); + }); +}); + describe("rejectionReason", () => { test("ignores write words inside strings, identifiers, comments, and replace()", () => { for (const sql of [ @@ -113,6 +137,7 @@ describe("rejectionReason", () => { "SELECT 1 /* insert later */", "SELECT replace(name, 'a', 'b') FROM users", "SELECT deleted_at, created_at FROM users", + "SELECT begin, release, analyze, col2delete FROM events", "-- a comment first\nSELECT 1", ]) { expect(rejectionReason(sql)).toBeNull(); @@ -131,6 +156,11 @@ describe("rejectionReason", () => { } }); + test("rejects more than one statement", () => { + expect(rejectionReason("SELECT 1; PRAGMA user_version = 5")).not.toBeNull(); + expect(rejectionReason("SELECT ';' AS semi")).toBeNull(); + }); + test("rejects PRAGMAs that set something", () => { for (const sql of [ "PRAGMA user_version = 5", diff --git a/packages/database-studio/src/query.ts b/packages/database-studio/src/query.ts index cbfb4d7d..9f2cca1e 100644 --- a/packages/database-studio/src/query.ts +++ b/packages/database-studio/src/query.ts @@ -23,26 +23,20 @@ const READ_KEYWORDS = new Set([ "VALUES", ]); -/** Keywords that only appear in statements that change something. */ +/** + * Keywords that can make a single statement write even after a read keyword + * (`WITH … DELETE`, `EXPLAIN` aside). Words like BEGIN or VACUUM can only + * start a statement, which the first-keyword check already covers, and + * leaving them out keeps columns named `begin` or `release` usable. + */ const WRITE_KEYWORDS = new Set([ "INSERT", "UPDATE", "DELETE", "REPLACE", - "UPSERT", "CREATE", "DROP", "ALTER", - "ATTACH", - "DETACH", - "VACUUM", - "REINDEX", - "ANALYZE", - "BEGIN", - "COMMIT", - "ROLLBACK", - "SAVEPOINT", - "RELEASE", ]); /** PRAGMAs that read schema and may name a table or index: `PRAGMA table_info(users)`. */ @@ -125,8 +119,13 @@ export function rejectionReason(statement: string): string | null { : `Studio only runs PRAGMAs that read, such as table_info(name) or user_version on its own. PRAGMA ${name || "?"} as written could change a setting.`; } + // One statement only, so nothing can follow the checked one. + if (code.includes(";")) { + return "Run one statement at a time: select it, or put the cursor in it, and press Run."; + } + // `REPLACE(...)` is the string function; `REPLACE INTO` is a write. - for (const match of code.matchAll(/[A-Za-z_]+/g)) { + for (const match of code.matchAll(/[A-Za-z_][A-Za-z0-9_$]*/g)) { const word = match[0].toUpperCase(); if (!WRITE_KEYWORDS.has(word)) continue; const after = code.slice(match.index + word.length); @@ -146,17 +145,29 @@ export function rejectionReason(statement: string): string | null { * SELECT. Only called for PRAGMAs `rejectionReason()` already allowed. */ export function pragmaAsSelect(statement: string): string | null { - const match = codeOnly(statement).match( - /^\s*PRAGMA\s+(?:\w+\s*\.\s*)?(\w+)\s*(?:\(\s*([^()]*?)\s*\))?\s*$/i, + const match = statement.match( + /^\s*PRAGMA\s+(?:(\w+)\s*\.\s*)?(\w+)\s*(?:\((.*)\))?\s*;?\s*$/is, ); if (!match) return null; - const name = match[1]?.toLowerCase(); - // The argument was blanked out if it was quoted, so read it from the original. - const raw = statement.match(/\(\s*([^()]*?)\s*\)\s*;?\s*$/)?.[1] ?? ""; - const arg = raw - .replace(/^(['"`])(.*)\1$/s, "$2") - .replace(/^\[(.*)\]$/s, "$1"); - return `SELECT * FROM pragma_${name}(${arg ? `'${arg.replace(/'/g, "''")}'` : ""})`; + const [, schema, name, rawArg = ""] = match; + const arg = unquote(rawArg.trim()); + // The table-valued form takes the PRAGMA's argument, then the schema. + const args = [...(arg ? [arg] : []), ...(schema ? [schema] : [])].map( + (a) => `'${a.replace(/'/g, "''")}'`, + ); + return `SELECT * FROM pragma_${name?.toLowerCase()}(${args.join(", ")})`; +} + +/** A quoted SQL name or string as its plain text: `"a""b"` → `a"b`, `'o''b'` → `o'b`. */ +function unquote(text: string): string { + const quote = text[0]; + if (text.length >= 2 && (quote === '"' || quote === "'" || quote === "`")) { + if (text.endsWith(quote)) { + return text.slice(1, -1).replaceAll(quote + quote, quote); + } + } + if (text.startsWith("[") && text.endsWith("]")) return text.slice(1, -1); + return text; } /** From 61ea175e92c8cb85c83b4b2f120a1f4f18c308b8 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 15:05:28 +0000 Subject: [PATCH 6/7] refactor(db): share studio helpers and split table view fetching and paging --- packages/cli/src/commands/db/studio.ts | 4 +- packages/database-adapter/src/index.ts | 6 +- packages/database-adapter/src/introspect.ts | 3 +- packages/database-rest/src/handler.ts | 2 +- packages/database-rest/src/index.ts | 2 +- packages/database-studio/client/src/api.ts | 1 - .../client/src/components/DataGrid.tsx | 32 +--- .../client/src/components/Overview.tsx | 9 +- .../client/src/components/Pager.tsx | 119 ++++++++++++ .../client/src/components/RowSheet.tsx | 29 +-- .../client/src/components/SchemaTab.tsx | 20 +- .../client/src/components/SqlConsole.tsx | 26 +-- .../client/src/components/TabBar.tsx | 5 +- .../client/src/components/TableView.tsx | 174 +++--------------- .../client/src/hooks/use-table-rows.ts | 44 +++++ .../client/src/lib/interaction.ts | 44 +++++ .../database-studio/client/src/lib/schema.ts | 25 +++ packages/database-studio/src/catalog.ts | 10 +- packages/database-studio/src/dev-server.ts | 2 +- packages/database-studio/src/server.ts | 46 ++--- 20 files changed, 319 insertions(+), 284 deletions(-) create mode 100644 packages/database-studio/client/src/components/Pager.tsx create mode 100644 packages/database-studio/client/src/hooks/use-table-rows.ts create mode 100644 packages/database-studio/client/src/lib/interaction.ts create mode 100644 packages/database-studio/client/src/lib/schema.ts diff --git a/packages/cli/src/commands/db/studio.ts b/packages/cli/src/commands/db/studio.ts index a50d5fd2..686c7799 100644 --- a/packages/cli/src/commands/db/studio.ts +++ b/packages/cli/src/commands/db/studio.ts @@ -1,6 +1,6 @@ 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, @@ -133,7 +133,7 @@ export const dbStudioCommand = defineCommand<{ // 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), diff --git a/packages/database-adapter/src/index.ts b/packages/database-adapter/src/index.ts index 27c55a9a..34cc0d64 100644 --- a/packages/database-adapter/src/index.ts +++ b/packages/database-adapter/src/index.ts @@ -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"; diff --git a/packages/database-adapter/src/introspect.ts b/packages/database-adapter/src/introspect.ts index 74317541..ed7a9e45 100644 --- a/packages/database-adapter/src/introspect.ts +++ b/packages/database-adapter/src/introspect.ts @@ -162,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[], diff --git a/packages/database-rest/src/handler.ts b/packages/database-rest/src/handler.ts index d40b2363..c1788b0a 100644 --- a/packages/database-rest/src/handler.ts +++ b/packages/database-rest/src/handler.ts @@ -27,7 +27,7 @@ export interface RestHandlerOptions { * the client as bigint (which JSON.stringify throws on) and BLOBs as * Uint8Array. Send them as a decimal string and a base64 string. */ -const encodeValue = (_key: string, value: unknown): unknown => { +export const encodeValue = (_key: string, value: unknown): unknown => { if (typeof value === "bigint") return value.toString(); if (value instanceof Uint8Array) return Buffer.from(value).toString("base64"); return value; diff --git a/packages/database-rest/src/index.ts b/packages/database-rest/src/index.ts index 75491eaf..20862948 100644 --- a/packages/database-rest/src/index.ts +++ b/packages/database-rest/src/index.ts @@ -2,7 +2,7 @@ export type { RequireAuthOptions } from "./auth.ts"; export { requireAuth } from "./auth.ts"; export type { DatabaseExecutor, ExecuteResult } from "./executor.ts"; export type { RestHandlerOptions } from "./handler.ts"; -export { createRestHandler } from "./handler.ts"; +export { createRestHandler, encodeValue } from "./handler.ts"; export type { Filter, FilterCondition, diff --git a/packages/database-studio/client/src/api.ts b/packages/database-studio/client/src/api.ts index dde74ba3..b93f30a5 100644 --- a/packages/database-studio/client/src/api.ts +++ b/packages/database-studio/client/src/api.ts @@ -21,7 +21,6 @@ export interface ViewDefinition { export interface StudioSchema { /** Which database is open, e.g. `db_… (host)`. */ label: string | null; - readOnly: boolean; /** The database token itself is read-only (studio created it), not just studio's checks. */ tokenReadOnly: boolean; tables: TableDefinition[]; diff --git a/packages/database-studio/client/src/components/DataGrid.tsx b/packages/database-studio/client/src/components/DataGrid.tsx index 08a526ff..9a53a90a 100644 --- a/packages/database-studio/client/src/components/DataGrid.tsx +++ b/packages/database-studio/client/src/components/DataGrid.tsx @@ -9,6 +9,7 @@ import { import { useEffect, useRef, useState } from "react"; import { CellValue } from "@/components/CellValue"; import { toText } from "@/lib/format"; +import { startDrag, useCopy } from "@/lib/interaction"; import { cn } from "@/lib/utils"; const NUMERIC = new Set(["INTEGER", "REAL"]); @@ -59,7 +60,7 @@ export function DataGrid({ }) { const [selected, setSelected] = useState(null); const [widths, setWidths] = useState>({}); - const [copied, setCopied] = useState(null); + const { copied, copy } = useCopy(900); const [followErrors, setFollowErrors] = useState>({}); const containerRef = useRef(null); @@ -79,14 +80,8 @@ export function DataGrid({ return w ? { width: w, minWidth: w, maxWidth: w } : undefined; }; - function copyCell(cell: Cell) { - const value = rows[cell.row]?.[cell.col]; - navigator.clipboard.writeText(toText(value)).then(() => { - const key = `${cell.row}:${cell.col}`; - setCopied(key); - setTimeout(() => setCopied((c) => (c === key ? null : c)), 900); - }); - } + const copyCell = (cell: Cell) => + copy(`${cell.row}:${cell.col}`, toText(rows[cell.row]?.[cell.col])); function onKeyDown(e: React.KeyboardEvent) { if (!selected || rows.length === 0) return; @@ -127,26 +122,15 @@ export function DataGrid({ } function startResize(e: React.PointerEvent, name: string) { - e.preventDefault(); - e.stopPropagation(); const th = (e.currentTarget as HTMLElement).closest("th"); if (!th) return; - const startX = e.clientX; const startWidth = th.getBoundingClientRect().width; - const onMove = (ev: PointerEvent) => + startDrag(e, (dx) => setWidths((w) => ({ ...w, - [name]: Math.max( - MIN_WIDTH, - Math.round(startWidth + ev.clientX - startX), - ), - })); - const onUp = () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - }; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); + [name]: Math.max(MIN_WIDTH, Math.round(startWidth + dx)), + })), + ); } return ( diff --git a/packages/database-studio/client/src/components/Overview.tsx b/packages/database-studio/client/src/components/Overview.tsx index 3d7870f5..22d12e7a 100644 --- a/packages/database-studio/client/src/components/Overview.tsx +++ b/packages/database-studio/client/src/components/Overview.tsx @@ -1,6 +1,7 @@ import { Eye, KeyRound, SquareTerminal, Table2 } from "lucide-react"; import type { StudioSchema } from "@/api"; import { Button } from "@/components/ui/button"; +import { incomingReferences } from "@/lib/schema"; function Stat({ label, value }: { label: string; value: number }) { return ( @@ -54,12 +55,6 @@ export function Overview({ const { tables, views, ddl } = schema; const triggers = (table: string) => (ddl[table] ?? []).filter((s) => /^\s*CREATE\s+TRIGGER/i.test(s)).length; - const incoming = (table: string) => - tables.reduce( - (n, t) => - n + t.foreignKeys.filter((fk) => fk.referencesTable === table).length, - 0, - ); const totals = { indexes: tables.reduce((n, t) => n + t.indexes.length, 0), triggers: tables.reduce((n, t) => n + triggers(t.name), 0), @@ -154,7 +149,7 @@ export function Overview({ - +
diff --git a/packages/database-studio/client/src/components/FilterBar.tsx b/packages/database-studio/client/src/components/FilterBar.tsx index 432fcb47..96e51f54 100644 --- a/packages/database-studio/client/src/components/FilterBar.tsx +++ b/packages/database-studio/client/src/components/FilterBar.tsx @@ -27,7 +27,6 @@ interface Draft extends FilterCondition { let nextId = 0; const draft = (f: FilterCondition): Draft => ({ ...f, id: nextId++ }); -/** A sensible first operator: text columns are usually searched, not matched exactly. */ function defaultOperator(column?: ColumnDefinition): OperatorId { return column?.type === "TEXT" ? "contains" : "eq"; } @@ -43,18 +42,15 @@ export function FilterBar({ mode: FilterMode; onApply: (filters: FilterCondition[], mode: FilterMode) => void; }) { - // Re-mounted (via key) whenever the applied filters change, so drafts start - // from what's in the URL. + // Re-mounted via key when the applied filters change, so drafts start from the URL. + const blank = () => + draft({ + column: columns[0]?.name ?? "", + operator: defaultOperator(columns[0]), + value: "", + }); const [drafts, setDrafts] = useState(() => - applied.length > 0 - ? applied.map(draft) - : [ - draft({ - column: columns[0]?.name ?? "", - operator: defaultOperator(columns[0]), - value: "", - }), - ], + applied.length > 0 ? applied.map(draft) : [blank()], ); const strip = (ds: Draft[]): FilterCondition[] => @@ -73,18 +69,6 @@ export function FilterBar({ onApply(strip(next), mode); } - function add() { - const column = columns[0]; - setDrafts((ds) => [ - ...ds, - draft({ - column: column?.name ?? "", - operator: defaultOperator(column), - value: "", - }), - ]); - } - return (
{drafts.map((d, i) => ( @@ -179,7 +163,7 @@ export function FilterBar({ variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs" - onClick={add} + onClick={() => setDrafts((ds) => [...ds, blank()])} > Add filter diff --git a/packages/database-studio/client/src/components/LoadingScreen.tsx b/packages/database-studio/client/src/components/LoadingScreen.tsx index 1896b209..6cf4015a 100644 --- a/packages/database-studio/client/src/components/LoadingScreen.tsx +++ b/packages/database-studio/client/src/components/LoadingScreen.tsx @@ -1,7 +1,7 @@ const LOADING_STEPS = [ - { label: "Connecting to database" }, - { label: "Fetching tables" }, - { label: "Preparing studio" }, + "Connecting to database", + "Fetching tables", + "Preparing studio", ]; export function LoadingScreen({ error }: { error: string | null }) { @@ -28,16 +28,14 @@ export function LoadingScreen({ error }: { error: string | null }) {
{LOADING_STEPS.map((step, i) => (
{String(i + 1).padStart(2, "0")} - - {step.label} - + {step}
))}
diff --git a/packages/database-studio/client/src/components/Overview.tsx b/packages/database-studio/client/src/components/Overview.tsx index 22d12e7a..81f0c9ed 100644 --- a/packages/database-studio/client/src/components/Overview.tsx +++ b/packages/database-studio/client/src/components/Overview.tsx @@ -36,7 +36,6 @@ const Th = ({ children }: { children?: React.ReactNode }) => ( ); -/** The database at a glance, like phpMyAdmin's Structure page. */ export function Overview({ schema, missing, @@ -45,10 +44,8 @@ export function Overview({ onOpenSql, }: { schema: StudioSchema; - /** A table named in the URL that isn't in the schema. */ missing: string | null; onSelect: (name: string, view?: "schema") => void; - /** Opens a table or view in the SQL console with a starter query. */ onSelectView: (name: string) => void; onOpenSql: () => void; }) { @@ -143,7 +140,7 @@ export function Overview({ {t.primaryKey.join(", ")} ) : ( - "—" + "-" )} diff --git a/packages/database-studio/client/src/components/Pager.tsx b/packages/database-studio/client/src/components/Pager.tsx index bcf0eca4..b9f0e374 100644 --- a/packages/database-studio/client/src/components/Pager.tsx +++ b/packages/database-studio/client/src/components/Pager.tsx @@ -10,7 +10,6 @@ import { Button } from "@/components/ui/button"; export const PAGE_SIZES = [25, 50, 100, 250]; export const DEFAULT_PAGE_SIZE = 50; -/** The table view's footer: which rows are showing, the page size, and page buttons. */ export function Pager({ data, page, diff --git a/packages/database-studio/client/src/components/Popover.tsx b/packages/database-studio/client/src/components/Popover.tsx index 27eb7f3e..174a4d73 100644 --- a/packages/database-studio/client/src/components/Popover.tsx +++ b/packages/database-studio/client/src/components/Popover.tsx @@ -1,10 +1,7 @@ import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; -/** - * A panel anchored under its trigger. Closes on outside click or Escape. - * Render it inside a `relative` wrapper alongside the trigger. - */ +// Render inside a `relative` wrapper alongside the trigger. export function Popover({ open, onClose, diff --git a/packages/database-studio/client/src/components/RowSheet.tsx b/packages/database-studio/client/src/components/RowSheet.tsx index 60185eca..612a851c 100644 --- a/packages/database-studio/client/src/components/RowSheet.tsx +++ b/packages/database-studio/client/src/components/RowSheet.tsx @@ -69,7 +69,6 @@ export function RowSheet({ > {entry && ( { // Clicking another row swaps the sheet's contents instead of closing it. diff --git a/packages/database-studio/client/src/components/SchemaTab.tsx b/packages/database-studio/client/src/components/SchemaTab.tsx index d7d4c41f..7642ba93 100644 --- a/packages/database-studio/client/src/components/SchemaTab.tsx +++ b/packages/database-studio/client/src/components/SchemaTab.tsx @@ -129,7 +129,7 @@ export function SchemaTab({
diff --git a/packages/database-studio/client/src/components/SqlConsole.tsx b/packages/database-studio/client/src/components/SqlConsole.tsx index 9e7aec8c..c23292af 100644 --- a/packages/database-studio/client/src/components/SqlConsole.tsx +++ b/packages/database-studio/client/src/components/SqlConsole.tsx @@ -5,7 +5,7 @@ import { DataGrid } from "@/components/DataGrid"; import { MenuItem, Popover } from "@/components/Popover"; import { SqlEditor } from "@/components/SqlEditor"; import { Button } from "@/components/ui/button"; -import { download, toCsv } from "@/lib/format"; +import { downloadRows } from "@/lib/format"; import { MOD_KEY, startDrag } from "@/lib/interaction"; import { quoteIdent } from "@/lib/sql"; import { cn } from "@/lib/utils"; @@ -43,7 +43,6 @@ function timeAgo(at: number): string { return new Date(at).toLocaleDateString(); } -/** A request from elsewhere in the app (e.g. opening a view) to load and run SQL. */ export interface ConsoleRequest { sql: string; nonce: number; @@ -120,18 +119,7 @@ export function SqlConsole({ function exportResult(format: "csv" | "json") { if (!result) return; - const objects = result.rows.map((r) => - Object.fromEntries(result.columns.map((c, i) => [c, r[i]])), - ); - if (format === "csv") { - download("query.csv", toCsv(result.columns, result.rows), "text/csv"); - } else { - download( - "query.json", - `${JSON.stringify(objects, null, 2)}\n`, - "application/json", - ); - } + downloadRows("query", result.columns, result.rows, format); setExportOpen(false); } @@ -144,11 +132,6 @@ export function SqlConsole({ ); } - const gridColumns = useMemo( - () => result?.columns.map((name) => ({ name })) ?? [], - [result], - ); - return (
@@ -252,7 +235,7 @@ export function SqlConsole({
) : result ? ( ({ name }))} rows={result.rows} emptyMessage={ result.columns.length === 0 diff --git a/packages/database-studio/client/src/components/SqlEditor.tsx b/packages/database-studio/client/src/components/SqlEditor.tsx index 986f55c6..5e6ccaa3 100644 --- a/packages/database-studio/client/src/components/SqlEditor.tsx +++ b/packages/database-studio/client/src/components/SqlEditor.tsx @@ -91,10 +91,6 @@ const highlight = HighlightStyle.define([ }, ]); -/** - * CodeMirror with SQLite highlighting and completion from the schema. - * Cmd/Ctrl+Enter runs the selection, or the statement under the cursor. - */ export function SqlEditor({ value, onChange, @@ -104,7 +100,6 @@ export function SqlEditor({ value: string; onChange: (value: string) => void; onRun: (sql: string) => void; - /** Table and view names to their columns, for autocomplete. */ schema: Record; }) { const hostRef = useRef(null); @@ -166,7 +161,6 @@ export function SqlEditor({ return () => view.destroy(); }, []); - // Outside changes (history, "open view") replace the document. useEffect(() => { const view = viewRef.current; if (!view || view.state.doc.toString() === value) return; diff --git a/packages/database-studio/client/src/components/TabBar.tsx b/packages/database-studio/client/src/components/TabBar.tsx index c52367c3..0aa052ac 100644 --- a/packages/database-studio/client/src/components/TabBar.tsx +++ b/packages/database-studio/client/src/components/TabBar.tsx @@ -96,7 +96,6 @@ export function TabBar({ type="button" onClick={() => onSelect(name)} onAuxClick={(e) => { - // Middle-click closes, as in a browser. if (e.button === 1) onClose(name); }} className="h-full pl-3 pr-1.5 font-mono text-xs" diff --git a/packages/database-studio/client/src/components/TablePicker.tsx b/packages/database-studio/client/src/components/TablePicker.tsx index d12974d5..b150df3e 100644 --- a/packages/database-studio/client/src/components/TablePicker.tsx +++ b/packages/database-studio/client/src/components/TablePicker.tsx @@ -9,7 +9,6 @@ interface Item { columns: number; } -/** Searchable list of tables and views with arrow-key navigation, shown in the tab bar's "+" popover. */ export function TablePicker({ tables, views, diff --git a/packages/database-studio/client/src/components/TableView.tsx b/packages/database-studio/client/src/components/TableView.tsx index 276bba4d..9df8da63 100644 --- a/packages/database-studio/client/src/components/TableView.tsx +++ b/packages/database-studio/client/src/components/TableView.tsx @@ -1,5 +1,5 @@ import { Columns3, Download, Filter, RefreshCw } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { OpenTable } from "@/App"; import { fetchRowWhere, type RowsQuery, type TableDefinition } from "@/api"; import { DataGrid, type GridColumn } from "@/components/DataGrid"; @@ -18,7 +18,7 @@ import { isComplete, isOperator, } from "@/lib/filters"; -import { download, toCsv } from "@/lib/format"; +import { downloadRows } from "@/lib/format"; import { cn } from "@/lib/utils"; function parseFilters( @@ -54,7 +54,6 @@ export function TableView({ }: { table: TableDefinition; tables: TableDefinition[]; - /** The table's CREATE statements, for the schema view. */ ddl: string[]; onOpenTable: OpenTable; }) { @@ -73,14 +72,8 @@ export function TableView({ const modeParam = useUrlParam("mode"); const view = viewParam === "schema" ? "schema" : "data"; - const columnNames = useMemo( - () => new Set(table.columns.map((c) => c.name)), - [table], - ); - const filters = useMemo( - () => parseFilters(filtersParam, columnNames), - [filtersParam, columnNames], - ); + const columnNames = new Set(table.columns.map((c) => c.name)); + const filters = parseFilters(filtersParam, columnNames); const mode: FilterMode = modeParam === "or" ? "or" : "and"; const [filtersOpen, setFiltersOpen] = useState(filters.length > 0); @@ -110,7 +103,6 @@ export function TableView({ } }, [data, page]); - // R refreshes, like a browser reload but without losing the session. useEffect(() => { function onKeyDown(e: KeyboardEvent) { const target = e.target as HTMLElement; @@ -132,7 +124,6 @@ export function TableView({ } function setSort(column: string) { - // Cycle: ascending, descending, unsorted. if (sort?.column !== column) { setUrlParams({ sort: column, order: null, page: null }); } else if (sort.order === "asc") { @@ -151,14 +142,11 @@ export function TableView({ }); } - const openTable = useCallback( - (name, nextFilters) => { - setSheet([]); - if (nextFilters?.length) setFiltersOpen(true); - onOpenTable(name, nextFilters); - }, - [onOpenTable], - ); + const openTable: OpenTable = (name, nextFilters) => { + setSheet([]); + if (nextFilters?.length) setFiltersOpen(true); + onOpenTable(name, nextFilters); + }; async function followForeignKey( from: TableDefinition, @@ -184,40 +172,28 @@ export function TableView({ () => table.columns.filter((c) => !hidden.has(c.name)), [table, hidden], ); - const gridColumns = useMemo(() => { - const fks = new Map(table.foreignKeys.map((fk) => [fk.column, fk])); - return visibleColumns.map((c) => { - const fk = fks.get(c.name); - return { - name: c.name, - type: c.type, - primaryKey: table.primaryKey.includes(c.name), - references: fk && `${fk.referencesTable}.${fk.referencesColumn}`, - }; - }); - }, [table, visibleColumns]); + const gridColumns: GridColumn[] = visibleColumns.map((c) => { + const fk = table.foreignKeys.find((f) => f.column === c.name); + return { + name: c.name, + type: c.type, + primaryKey: table.primaryKey.includes(c.name), + references: fk && `${fk.referencesTable}.${fk.referencesColumn}`, + }; + }); + // Stable rows matter: DataGrid clears its cell selection when they change. const gridRows = useMemo( () => data?.rows.map((r) => visibleColumns.map((c) => r[c.name])) ?? [], [data, visibleColumns], ); - const offset = (page - 1) * limit; function exportPage(format: "csv" | "json") { - if (!data) return; - const names = visibleColumns.map((c) => c.name); - const base = `${table.name}-page-${page}`; - if (format === "csv") { - download(`${base}.csv`, toCsv(names, gridRows), "text/csv"); - } else { - const rows = gridRows.map((r) => - Object.fromEntries(names.map((n, i) => [n, r[i]])), - ); - download( - `${base}.json`, - `${JSON.stringify(rows, null, 2)}\n`, - "application/json", - ); - } + downloadRows( + `${table.name}-page-${page}`, + visibleColumns.map((c) => c.name), + gridRows, + format, + ); setExportOpen(false); } @@ -313,48 +289,48 @@ export function TableView({ )}
- - )} - {view === "data" && ( -
-
+
+
+ + setExportOpen(false)} + align="right" + className="w-44" + > + exportPage("csv")}> + This page as CSV + + exportPage("json")}> + This page as JSON + + +
- setExportOpen(false)} - align="right" - className="w-44" - > - exportPage("csv")}> - This page as CSV - - exportPage("json")}> - This page as JSON - -
- -
+ )}
@@ -410,7 +386,7 @@ export function TableView({ , - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SelectLabel.displayName = SelectPrimitive.Label.displayName; - const SelectItem = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef @@ -131,27 +117,4 @@ const SelectItem = React.forwardRef< )); SelectItem.displayName = SelectPrimitive.Item.displayName; -const SelectSeparator = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SelectSeparator.displayName = SelectPrimitive.Separator.displayName; - -export { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectScrollDownButton, - SelectScrollUpButton, - SelectSeparator, - SelectTrigger, - SelectValue, -}; +export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }; diff --git a/packages/database-studio/client/src/components/ui/sheet.tsx b/packages/database-studio/client/src/components/ui/sheet.tsx index c5c43dcb..d5ab80a6 100644 --- a/packages/database-studio/client/src/components/ui/sheet.tsx +++ b/packages/database-studio/client/src/components/ui/sheet.tsx @@ -1,64 +1,21 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"; -import { cva, type VariantProps } from "class-variance-authority"; import { X } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/utils"; const Sheet = SheetPrimitive.Root; -const SheetTrigger = SheetPrimitive.Trigger; -const SheetClose = SheetPrimitive.Close; -const SheetPortal = SheetPrimitive.Portal; - -const SheetOverlay = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; - -const sheetVariants = cva( - "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", - { - variants: { - side: { - top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", - bottom: - "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", - left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", - right: - "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", - }, - }, - defaultVariants: { - side: "right", - }, - }, -); - -interface SheetContentProps - extends React.ComponentPropsWithoutRef, - VariantProps { - /** Hide the overlay (useful for stacked sheets). */ - hideOverlay?: boolean; -} const SheetContent = React.forwardRef< React.ComponentRef, - SheetContentProps ->(({ side = "right", className, children, hideOverlay, ...props }, ref) => ( - - {!hideOverlay && } + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + @@ -67,7 +24,7 @@ const SheetContent = React.forwardRef< {children} - + )); SheetContent.displayName = SheetPrimitive.Content.displayName; @@ -105,14 +62,4 @@ const SheetDescription = React.forwardRef< )); SheetDescription.displayName = SheetPrimitive.Description.displayName; -export { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetHeader, - SheetOverlay, - SheetPortal, - SheetTitle, - SheetTrigger, -}; +export { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle }; diff --git a/packages/database-studio/client/src/hooks/use-table-rows.ts b/packages/database-studio/client/src/hooks/use-table-rows.ts index 9f756f7a..c3471afd 100644 --- a/packages/database-studio/client/src/hooks/use-table-rows.ts +++ b/packages/database-studio/client/src/hooks/use-table-rows.ts @@ -1,18 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchRows, type RowsQuery, type RowsResponse } from "@/api"; -/** - * One page of a table's rows. Refetches when the query changes, cancels a - * fetch that a newer one replaces, and keeps the last page on screen while - * the next loads. - */ +// Keeps the last page on screen while the next loads. export function useTableRows(table: string, query: RowsQuery) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reloadKey, setReloadKey] = useState(0); - // Compare by value so a re-render with an equal query doesn't refetch. + // Compared by value so an equal query doesn't refetch. const queryKey = JSON.stringify(query); const stableQuery = useMemo( () => JSON.parse(queryKey) as RowsQuery, diff --git a/packages/database-studio/client/src/hooks/use-url-state.ts b/packages/database-studio/client/src/hooks/use-url-state.ts index df2fdcef..0fc74f1a 100644 --- a/packages/database-studio/client/src/hooks/use-url-state.ts +++ b/packages/database-studio/client/src/hooks/use-url-state.ts @@ -9,17 +9,12 @@ function subscribe(callback: () => void) { return () => window.removeEventListener("popstate", callback); } -/** Read a single URL search param reactively. */ export function useUrlParam(key: string): string | null { const search = useSyncExternalStore(subscribe, getSearch); return new URLSearchParams(search).get(key); } -/** - * Update URL search params without a page reload. `push` adds a history - * entry so Back returns to the previous view (used for navigation, not for - * every filter keystroke). - */ +// `push` is for navigation only, so Back skips individual filter and paging edits. export function setUrlParams( updates: Record, { push = false }: { push?: boolean } = {}, @@ -40,11 +35,9 @@ export function setUrlParams( } else { window.history.replaceState(null, "", url); } - // Notify subscribers window.dispatchEvent(new PopStateEvent("popstate")); } -/** Params scoped to one table's view; cleared when switching tables. */ export const TABLE_VIEW_PARAMS = { view: null, page: null, @@ -54,5 +47,5 @@ export const TABLE_VIEW_PARAMS = { mode: null, } as const; -/** The `table` param value for the SQL console; not a valid unquoted table name. */ +// Not a valid unquoted table name, so it can't clash with one. export const SQL_TAB = ":sql"; diff --git a/packages/database-studio/client/src/index.css b/packages/database-studio/client/src/index.css index 72b0bdb0..0bd32aec 100644 --- a/packages/database-studio/client/src/index.css +++ b/packages/database-studio/client/src/index.css @@ -40,7 +40,7 @@ } .dark { - /* bunny.net branding — deep navy + orange */ + /* bunny.net branding: deep navy + orange */ --background: oklch(0.232 0.06 251.2); /* #041E38 */ --foreground: oklch(1 0 0); /* white */ --card: oklch(0.252 0.066 250.5); /* #042340 */ diff --git a/packages/database-studio/client/src/lib/filters.ts b/packages/database-studio/client/src/lib/filters.ts index 4ccd2dee..b7cf0aed 100644 --- a/packages/database-studio/client/src/lib/filters.ts +++ b/packages/database-studio/client/src/lib/filters.ts @@ -8,14 +8,11 @@ export interface FilterCondition { interface Operator { label: string; - /** Operators like IS NULL take no value. */ nullary?: boolean; - /** The `op.value` part of a REST filter (`not.` prefix included). */ encode: (value: string) => string; } -// LIKE patterns: `%` and `_` in the user's value stay wildcards, which is -// what people typing into a "contains" box usually expect anyway. +// `%` and `_` in a "contains" value stay wildcards on purpose. export const OPERATORS = { eq: { label: "equals", encode: (v) => `eq.${v}` }, neq: { label: "not equal", encode: (v) => `neq.${v}` }, @@ -58,12 +55,10 @@ export function isNullary(id: OperatorId): boolean { return "nullary" in OPERATORS[id]; } -/** A filter is ready to send once it has a value, unless its operator needs none. */ export function isComplete(f: FilterCondition): boolean { return isNullary(f.operator) || f.value !== ""; } -/** Quote a value for an `or=(...)` group when it carries characters the group syntax uses. */ function quoteForGroup(encoded: string): string { const dot = encoded.startsWith("not.") ? encoded.indexOf(".", 4) @@ -75,13 +70,11 @@ function quoteForGroup(encoded: string): string { return `${op}"${value.replace(/[\\"]/g, "\\$&")}"`; } -/** Query parameters the REST handler reads as paging or options, not as column filters. */ const RESERVED_PARAMS = new Set(["select", "order", "limit", "offset", "or"]); const groupPart = (f: FilterCondition) => `${f.column}.${quoteForGroup(OPERATORS[f.operator].encode(f.value))}`; -/** Query parameters for the REST handler: one per filter, or a single `or=(...)` group. */ export function filterParams( filters: FilterCondition[], mode: FilterMode, @@ -91,8 +84,7 @@ export function filterParams( return [["or", `(${ready.map(groupPart).join(",")})`]]; } return ready.map((f): [string, string] => - // A column named `limit` or `order` would collide with the paging - // parameter, so its filter goes in a one-condition group instead. + // A column named like a paging param (`limit`, `order`) would collide, so it goes in a group. RESERVED_PARAMS.has(f.column) ? ["or", `(${groupPart(f)})`] : [f.column, OPERATORS[f.operator].encode(f.value)], diff --git a/packages/database-studio/client/src/lib/format.ts b/packages/database-studio/client/src/lib/format.ts index 51fa60c1..ff1ec55c 100644 --- a/packages/database-studio/client/src/lib/format.ts +++ b/packages/database-studio/client/src/lib/format.ts @@ -1,11 +1,9 @@ -/** Plain-text form of a cell, as copied to the clipboard or exported. */ export function toText(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "object") return JSON.stringify(value); return String(value); } -/** Parse a TEXT value that holds a JSON object or array, for pretty-printing. */ export function parseJsonObject(value: unknown): object | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -24,7 +22,7 @@ export function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MB`; } -/** BLOBs arrive base64-encoded; this is their decoded size. */ +// BLOBs arrive base64-encoded. export function base64Size(value: string): number { const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; return Math.floor((value.length * 3) / 4) - padding; @@ -35,8 +33,8 @@ function csvField(value: unknown): string { return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } -/** CSV with a header row; rows are positional so repeated column names keep their own values. */ -export function toCsv(columns: string[], rows: unknown[][]): string { +// Rows are positional so repeated column names keep their own values. +function toCsv(columns: string[], rows: unknown[][]): string { const lines = [columns.map(csvField).join(",")]; for (const row of rows) { lines.push(row.map(csvField).join(",")); @@ -44,7 +42,7 @@ export function toCsv(columns: string[], rows: unknown[][]): string { return `${lines.join("\r\n")}\r\n`; } -export function download(filename: string, contents: string, type: string) { +function download(filename: string, contents: string, type: string) { const url = URL.createObjectURL(new Blob([contents], { type })); const a = document.createElement("a"); a.href = url; @@ -52,3 +50,23 @@ export function download(filename: string, contents: string, type: string) { a.click(); URL.revokeObjectURL(url); } + +export function downloadRows( + name: string, + columns: string[], + rows: unknown[][], + format: "csv" | "json", +) { + if (format === "csv") { + download(`${name}.csv`, toCsv(columns, rows), "text/csv"); + return; + } + const objects = rows.map((r) => + Object.fromEntries(columns.map((c, i) => [c, r[i]])), + ); + download( + `${name}.json`, + `${JSON.stringify(objects, null, 2)}\n`, + "application/json", + ); +} diff --git a/packages/database-studio/client/src/lib/interaction.ts b/packages/database-studio/client/src/lib/interaction.ts index 8297e013..575d2561 100644 --- a/packages/database-studio/client/src/lib/interaction.ts +++ b/packages/database-studio/client/src/lib/interaction.ts @@ -1,14 +1,9 @@ import { useCallback, useState } from "react"; -export const isMac = /Mac|iPhone|iPad/.test(navigator.platform); +export const MOD_KEY = /Mac|iPhone|iPad/.test(navigator.platform) + ? "⌘" + : "Ctrl "; -/** The platform's modifier key as shown in shortcut hints. */ -export const MOD_KEY = isMac ? "⌘" : "Ctrl "; - -/** - * Copy text and remember which thing was copied for a moment, so the caller - * can flash a check mark next to it. - */ export function useCopy(duration = 1200) { const [copied, setCopied] = useState(null); const copy = useCallback( @@ -23,10 +18,7 @@ export function useCopy(duration = 1200) { return { copied, copy }; } -/** - * Track a pointer drag that started with `e`, reporting how far it has moved. - * Listens on the window so the drag survives leaving the handle. - */ +// Listens on the window so the drag survives leaving the handle. export function startDrag( e: React.PointerEvent, onMove: (dx: number, dy: number) => void, diff --git a/packages/database-studio/client/src/lib/schema.ts b/packages/database-studio/client/src/lib/schema.ts index 3ea2c92f..4e23e6a7 100644 --- a/packages/database-studio/client/src/lib/schema.ts +++ b/packages/database-studio/client/src/lib/schema.ts @@ -1,18 +1,6 @@ import type { TableDefinition } from "@/api"; -export interface IncomingReference { - /** The table holding the foreign key. */ - table: string; - column: string; - /** The column it points at in the referenced table. */ - referencesColumn: string; -} - -/** Foreign keys in any table that point at `table`. */ -export function incomingReferences( - tables: TableDefinition[], - table: string, -): IncomingReference[] { +export function incomingReferences(tables: TableDefinition[], table: string) { return tables.flatMap((t) => t.foreignKeys .filter((fk) => fk.referencesTable === table) diff --git a/packages/database-studio/client/src/lib/sql.test.ts b/packages/database-studio/client/src/lib/sql.test.ts index 85e0c5ce..4e775d4d 100644 --- a/packages/database-studio/client/src/lib/sql.test.ts +++ b/packages/database-studio/client/src/lib/sql.test.ts @@ -1,32 +1,17 @@ -import { describe, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import { quoteIdent, statementAt } from "./sql.ts"; -describe("statementAt", () => { - const doc = "SELECT 1;\nSELECT ';' AS semi;\n-- note; here\nSELECT 3"; - - test("returns the statement under the cursor", () => { - expect(statementAt(doc, 3)).toBe("SELECT 1"); - expect(statementAt(doc, doc.indexOf("AS semi"))).toBe("SELECT ';' AS semi"); - expect(statementAt(doc, doc.length)).toBe("-- note; here\nSELECT 3"); - }); - - test("a cursor just after a semicolon runs the statement it ends", () => { - expect(statementAt(doc, doc.indexOf(";") + 1)).toBe("SELECT 1"); - }); - - test("a cursor on trailing blank lines runs the last statement", () => { - expect(statementAt("SELECT 1;\n\n", 11)).toBe("SELECT 1"); - }); +test("statementAt ignores semicolons in quotes and comments", () => { + const doc = "SELECT ';' AS semi;\n-- note; here\nSELECT 3"; + expect(statementAt(doc, 3)).toBe("SELECT ';' AS semi"); + expect(statementAt(doc, doc.length)).toBe("-- note; here\nSELECT 3"); +}); - test("a single statement without a semicolon is the whole document", () => { - expect(statementAt(" SELECT * FROM t ", 0)).toBe("SELECT * FROM t"); - }); +test("statementAt runs the statement a cursor just after or below it ends", () => { + expect(statementAt("SELECT 1;\nSELECT 2", 9)).toBe("SELECT 1"); + expect(statementAt("SELECT 1;\n\n", 11)).toBe("SELECT 1"); }); -describe("quoteIdent", () => { - test("quotes every name, so keywords work as table names", () => { - expect(quoteIdent("order")).toBe('"order"'); - expect(quoteIdent("user roles")).toBe('"user roles"'); - expect(quoteIdent('a"b')).toBe('"a""b"'); - }); +test("quoteIdent escapes embedded quotes", () => { + expect(quoteIdent('a"b')).toBe('"a""b"'); }); diff --git a/packages/database-studio/client/src/lib/sql.ts b/packages/database-studio/client/src/lib/sql.ts index 64fa3218..db418283 100644 --- a/packages/database-studio/client/src/lib/sql.ts +++ b/packages/database-studio/client/src/lib/sql.ts @@ -1,8 +1,4 @@ -/** - * The statement around `pos`: the text between the semicolons on either side, - * ignoring semicolons inside quotes and comments. Lets Cmd+Enter run the - * statement under the cursor when the editor holds several. - */ +// Lets Cmd+Enter run the statement under the cursor; skips semicolons in quotes and comments. export function statementAt(doc: string, pos: number): string { const bounds: number[] = []; let quote: string | null = null; @@ -43,12 +39,11 @@ export function statementAt(doc: string, pos: number): string { } } const statement = doc.slice(start, end).trim(); - // The cursor on a blank line after the last statement means that statement. if (!statement && start > 0) return statementAt(doc, start - 1); return statement; } -/** Always quoted: a bare name can be a keyword (`order`, `group`). */ +// Always quoted: a bare name can be a keyword (`order`, `group`). export function quoteIdent(name: string): string { return `"${name.replace(/"/g, '""')}"`; } diff --git a/packages/database-studio/client/src/main.tsx b/packages/database-studio/client/src/main.tsx index 31a8695f..b1ef9b66 100644 --- a/packages/database-studio/client/src/main.tsx +++ b/packages/database-studio/client/src/main.tsx @@ -3,9 +3,7 @@ import { createRoot } from "react-dom/client"; import { App } from "./App.tsx"; import "./index.css"; -// If the auto-opened URL carries ?token=…, exchange it for an HttpOnly auth -// cookie before rendering, then scrub it from the URL so it doesn't linger in -// history or bookmarks. Subsequent /api/* calls rely on the cookie being set. +// Swap ?token= for the HttpOnly auth cookie, then scrub it so it never lingers in history. function exchangeToken(): Promise { const params = new URLSearchParams(window.location.search); const token = params.get("token"); diff --git a/packages/database-studio/src/catalog.ts b/packages/database-studio/src/catalog.ts index 0283e4cb..efb21b64 100644 --- a/packages/database-studio/src/catalog.ts +++ b/packages/database-studio/src/catalog.ts @@ -23,10 +23,7 @@ const quote = (name: string) => `"${name.replace(/"/g, '""')}"`; const excluded = (name: string) => name.startsWith("sqlite_") || !shouldInclude(name, DEFAULT_EXCLUDE_PATTERNS); -/** - * A view's columns, or none when SQLite can't resolve it (a view over a - * dropped table): one stale view must not stop studio from starting. - */ +/** A view's columns, or none when it can't resolve (a view over a dropped table). */ async function viewColumns( client: CatalogClient, name: string, @@ -41,11 +38,7 @@ async function viewColumns( } } -/** - * What introspection leaves out and the schema view wants: the CREATE - * statements as written (including triggers, which introspection doesn't - * read), and views with their columns. - */ +/** The CREATE statements (including triggers) and views that introspection leaves out. */ export async function loadCatalog( client: CatalogClient, tableNames: Set, diff --git a/packages/database-studio/src/client-manifest.ts b/packages/database-studio/src/client-manifest.ts index 2521e6f7..11fd38b8 100644 --- a/packages/database-studio/src/client-manifest.ts +++ b/packages/database-studio/src/client-manifest.ts @@ -2,9 +2,7 @@ import indexCss from "../dist/client/assets/index.css" with { type: "file" }; import indexJs from "../dist/client/assets/index.js" with { type: "file" }; import indexHtml from "../dist/client/index.html" with { type: "file" }; -// `with { type: "file" }` resolves to a file path string at runtime, but TS -// resolves the real module when the built artifact exists on disk (overriding -// the ambient `*.js`/`*.html` declarations), so cast through `unknown`. +// These are file paths at runtime; TS sees the real modules once the build exists. export const assets: Record = { "/index.html": indexHtml as unknown as string, "/assets/index.js": indexJs as unknown as string, diff --git a/packages/database-studio/src/modules.d.ts b/packages/database-studio/src/modules.d.ts index c8623b83..cfab5c5c 100644 --- a/packages/database-studio/src/modules.d.ts +++ b/packages/database-studio/src/modules.d.ts @@ -1,6 +1,4 @@ -// Bun's `with { type: "file" }` returns a resolved file path as a string at -// runtime, but `@types/bun` only declares ambient modules for a few extensions. -// Declare the ones we import in `client-manifest.ts`. +// `with { type: "file" }` imports are paths; @types/bun doesn't declare these extensions. declare module "*.js" { const path: string; export default path; diff --git a/packages/database-studio/src/query.test.ts b/packages/database-studio/src/query.test.ts index 83aeab34..488666d8 100644 --- a/packages/database-studio/src/query.test.ts +++ b/packages/database-studio/src/query.test.ts @@ -1,4 +1,3 @@ -import { Database as Sqlite } from "bun:sqlite"; import { beforeEach, describe, expect, test } from "bun:test"; import { DatabaseError, type SqlValue } from "@bunny.net/database-client"; import { @@ -11,57 +10,33 @@ import { runReadOnlyQuery, } from "./query.ts"; -let db: Sqlite; let sent: string[]; +let reply: { columns: string[]; rows: SqlValue[][] }; -/** Mirrors the HTTP client's runRaw(), recording what reaches the database. */ const client: QueryClient = { prepare: (sql: string) => ({ runRaw: async () => { sent.push(sql); - try { - const query = db.query(sql); - return { - columns: query.columnNames, - rows: query.values() as SqlValue[][], - rowsAffected: 0, - lastInsertRowid: null, - }; - } catch (err) { - throw new DatabaseError((err as Error).message, { - code: "SQLITE_ERROR", - }); - } + return { ...reply, rowsAffected: 0, lastInsertRowid: null }; }, }) as never, }; -const count = () => - (db.query("SELECT count(*) AS n FROM users").get() as { n: number }).n; - beforeEach(() => { sent = []; - db = new Sqlite(":memory:"); - db.exec(` - CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); - INSERT INTO users (name) VALUES ('Alice'), ('Bob'); - `); + reply = { columns: [], rows: [] }; }); describe("runReadOnlyQuery", () => { test("returns positional rows, keeping duplicate column names", async () => { + reply = { columns: ["id", "id"], rows: [[1, 1]] }; const result = await runReadOnlyQuery( client, - "SELECT a.id, b.id FROM users a JOIN users b ON a.id = b.id ORDER BY a.id;", + "SELECT a.id, b.id FROM users a JOIN users b ON a.id = b.id", ); - expect(result.columns).toEqual(["id", "id"]); - expect(result.rows).toEqual([ - [1, 1], - [2, 2], - ]); - expect(result.truncated).toBe(false); + expect(result).toMatchObject({ ...reply, truncated: false }); }); test("sends only the statement itself, nothing libSQL would reject", async () => { @@ -70,10 +45,8 @@ describe("runReadOnlyQuery", () => { }); test("runs read-only PRAGMAs in their SELECT form, which a read-only token allows", async () => { - const result = await runReadOnlyQuery(client, "PRAGMA table_info('users')"); - + await runReadOnlyQuery(client, "PRAGMA table_info('users')"); expect(sent).toEqual(["SELECT * FROM pragma_table_info('users')"]); - expect(result.rows.map((r) => r[1])).toEqual(["id", "name"]); await runReadOnlyQuery(client, "PRAGMA user_version;"); expect(sent[1]).toBe("SELECT * FROM pragma_user_version()"); @@ -83,7 +56,6 @@ describe("runReadOnlyQuery", () => { for (const sql of [ "DELETE FROM users", "WITH doomed AS (SELECT id FROM users) DELETE FROM users WHERE id IN (SELECT id FROM doomed)", - "-- looks harmless\nWITH x AS (SELECT 1) UPDATE users SET name = 'x'", "WITH x AS (SELECT 1) REPLACE INTO users (id, name) VALUES (1, 'x')", ]) { await expect(runReadOnlyQuery(client, sql)).rejects.toBeInstanceOf( @@ -91,14 +63,14 @@ describe("runReadOnlyQuery", () => { ); } expect(sent).toEqual([]); - expect(count()).toBe(2); }); test("caps the rows it returns and says so", async () => { - const result = await runReadOnlyQuery( - client, - `WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${MAX_ROWS + 5}) SELECT i FROM n`, - ); + reply = { + columns: ["i"], + rows: Array.from({ length: MAX_ROWS + 5 }, (_, i) => [i]), + }; + const result = await runReadOnlyQuery(client, "SELECT i FROM n"); expect(result.rows).toHaveLength(MAX_ROWS); expect(result.truncated).toBe(true); @@ -107,24 +79,15 @@ describe("runReadOnlyQuery", () => { describe("pragmaAsSelect", () => { test("passes the argument as a plain string and keeps the schema", () => { - expect(pragmaAsSelect("PRAGMA table_info(users)")).toBe( - "SELECT * FROM pragma_table_info('users')", - ); expect(pragmaAsSelect('PRAGMA table_info("my""t")')).toBe( `SELECT * FROM pragma_table_info('my"t')`, ); - expect(pragmaAsSelect("PRAGMA table_info('o''b')")).toBe( - "SELECT * FROM pragma_table_info('o''b')", - ); expect(pragmaAsSelect('PRAGMA table_info("a(b)");')).toBe( "SELECT * FROM pragma_table_info('a(b)')", ); expect(pragmaAsSelect("PRAGMA aux.table_info(t)")).toBe( "SELECT * FROM pragma_table_info('t', 'aux')", ); - expect(pragmaAsSelect("PRAGMA aux.user_version")).toBe( - "SELECT * FROM pragma_user_version('aux')", - ); }); }); @@ -132,13 +95,10 @@ describe("rejectionReason", () => { test("ignores write words inside strings, identifiers, comments, and replace()", () => { for (const sql of [ "SELECT 'please delete me' AS note", - 'SELECT "update" FROM t', "SELECT [drop] FROM t", "SELECT 1 /* insert later */", "SELECT replace(name, 'a', 'b') FROM users", - "SELECT deleted_at, created_at FROM users", "SELECT begin, release, analyze, col2delete FROM events", - "-- a comment first\nSELECT 1", ]) { expect(rejectionReason(sql)).toBeNull(); } @@ -148,9 +108,7 @@ describe("rejectionReason", () => { for (const sql of [ "PRAGMA table_info(users)", "PRAGMA main.index_list('users')", - "PRAGMA foreign_key_list(users)", "PRAGMA user_version", - "PRAGMA compile_options", ]) { expect(rejectionReason(sql)).toBeNull(); } @@ -166,7 +124,6 @@ describe("rejectionReason", () => { "PRAGMA user_version = 5", "PRAGMA user_version(5)", "PRAGMA query_only = ON", - "PRAGMA writable_schema = 1", "PRAGMA table_info = users", ]) { expect(rejectionReason(sql)).not.toBeNull(); @@ -175,11 +132,11 @@ describe("rejectionReason", () => { }); describe("queryErrorMessage", () => { - test("shows SQL errors, which are about the person's own query", async () => { - const err = await runReadOnlyQuery(client, "SELECT nope FROM users").catch( - (e) => e, - ); - expect(queryErrorMessage(err)).toContain("no such column"); + test("shows SQL errors, which are about the person's own query", () => { + const err = new DatabaseError("no such column: nope", { + code: "SQLITE_ERROR", + }); + expect(queryErrorMessage(err)).toBe("no such column: nope"); }); test("explains a statement the read-only token may not run", () => { diff --git a/packages/database-studio/src/query.ts b/packages/database-studio/src/query.ts index 9f2cca1e..7863685f 100644 --- a/packages/database-studio/src/query.ts +++ b/packages/database-studio/src/query.ts @@ -7,12 +7,10 @@ export interface QueryResult { columns: string[]; /** Positional, so duplicate column names (`a.id`, `b.id`) both survive. */ rows: SqlValue[][]; - /** True when the query returned more than `MAX_ROWS` rows and the rest were dropped. */ truncated: boolean; durationMs: number; } -/** Rows sent to the browser per query; the console says when a result was cut. */ export const MAX_ROWS = 1000; const READ_KEYWORDS = new Set([ @@ -23,12 +21,7 @@ const READ_KEYWORDS = new Set([ "VALUES", ]); -/** - * Keywords that can make a single statement write even after a read keyword - * (`WITH … DELETE`, `EXPLAIN` aside). Words like BEGIN or VACUUM can only - * start a statement, which the first-keyword check already covers, and - * leaving them out keeps columns named `begin` or `release` usable. - */ +// Writes that can follow a read keyword (`WITH … DELETE`); statement-initial words like BEGIN are caught by the first-keyword check. const WRITE_KEYWORDS = new Set([ "INSERT", "UPDATE", @@ -39,7 +32,7 @@ const WRITE_KEYWORDS = new Set([ "ALTER", ]); -/** PRAGMAs that read schema and may name a table or index: `PRAGMA table_info(users)`. */ +/** Read-only PRAGMAs that may take a table or index name. */ const SCHEMA_PRAGMAS = new Set([ "table_info", "table_xinfo", @@ -53,7 +46,7 @@ const SCHEMA_PRAGMAS = new Set([ "quick_check", ]); -/** PRAGMAs that report a setting; with an argument or `=` they'd change it, so they must stand alone. */ +/** PRAGMAs that read a setting only when they stand alone; an argument or `=` sets it. */ const STATUS_PRAGMAS = new Set([ "compile_options", "database_list", @@ -76,10 +69,7 @@ const STATUS_PRAGMAS = new Set([ /** A query studio won't run; the message is safe to show as-is. */ export class QueryRejected extends Error {} -/** - * The SQL with comments, string literals, and quoted identifiers blanked - * out, so keyword checks only see the statement's own words. - */ +/** The SQL with comments, strings, and quoted identifiers blanked out. */ function codeOnly(sql: string): string { return sql.replace( /--[^\n]*|\/\*[\s\S]*?\*\/|'(?:[^']|'')*'|"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]/g, @@ -87,8 +77,7 @@ function codeOnly(sql: string): string { ); } -/** The first keyword after any leading whitespace and comments. */ -export function firstKeyword(sql: string): string | null { +function firstKeyword(sql: string): string | null { return ( codeOnly(sql) .trim() @@ -119,7 +108,6 @@ export function rejectionReason(statement: string): string | null { : `Studio only runs PRAGMAs that read, such as table_info(name) or user_version on its own. PRAGMA ${name || "?"} as written could change a setting.`; } - // One statement only, so nothing can follow the checked one. if (code.includes(";")) { return "Run one statement at a time: select it, or put the cursor in it, and press Run."; } @@ -138,12 +126,7 @@ export function rejectionReason(statement: string): string | null { return null; } -/** - * `PRAGMA name(arg)` as `SELECT * FROM pragma_name('arg')`. libSQL servers - * classify some read-only PRAGMAs (foreign_key_list, user_version) as - * writes, so a read-only token can't run them, but the table-valued form is a - * SELECT. Only called for PRAGMAs `rejectionReason()` already allowed. - */ +/** libSQL treats some read-only PRAGMAs as writes, so run them in their table-valued SELECT form. */ export function pragmaAsSelect(statement: string): string | null { const match = statement.match( /^\s*PRAGMA\s+(?:(\w+)\s*\.\s*)?(\w+)\s*(?:\((.*)\))?\s*;?\s*$/is, @@ -151,7 +134,6 @@ export function pragmaAsSelect(statement: string): string | null { if (!match) return null; const [, schema, name, rawArg = ""] = match; const arg = unquote(rawArg.trim()); - // The table-valued form takes the PRAGMA's argument, then the schema. const args = [...(arg ? [arg] : []), ...(schema ? [schema] : [])].map( (a) => `'${a.replace(/'/g, "''")}'`, ); @@ -170,14 +152,7 @@ function unquote(text: string): string { return text; } -/** - * Run one statement from the SQL console. Writes are stopped at two layers: - * studio rejects statements that contain write keywords, and a libSQL - * server refuses anything its parser classifies as a write when the token is - * read-only, which it is whenever studio created the token. (SQLite's - * `PRAGMA query_only` would be a neater guard, but libSQL servers reject - * that statement outright.) - */ +/** Runs one console statement; writes are refused here and, with a CLI-created token, by the server too. */ export async function runReadOnlyQuery( client: QueryClient, sql: string, @@ -207,12 +182,7 @@ export async function runReadOnlyQuery( const AUTH_ERROR = /not authori[sz]ed|unauthori[sz]ed|forbidden|permission|read.?only|readonly/i; -/** - * The message to show the person who typed the query. SQL errors (syntax, - * unknown column) are about their own query, so they're shown; scope and - * token failures get an explanation; anything else stays generic and goes to - * the terminal. - */ +/** A message safe to show in the browser, or null to log the error and show a generic one. */ export function queryErrorMessage(err: unknown): string | null { if (err instanceof QueryRejected) return err.message; if (!(err instanceof DatabaseError)) return null; diff --git a/packages/database-studio/src/server.ts b/packages/database-studio/src/server.ts index ec13d73a..878d1620 100644 --- a/packages/database-studio/src/server.ts +++ b/packages/database-studio/src/server.ts @@ -13,13 +13,9 @@ import { queryErrorMessage, runReadOnlyQuery } from "./query.ts"; export interface StudioOptions { client: Database; - /** Shown in the studio header so it's clear which database is open (e.g. its ID and host). */ + /** Shown in the studio header so it's clear which database is open. */ label?: string; - /** - * True when the connection's token is known to be read-only (the CLI - * created it). Otherwise the SQL console relies on studio's own statement - * checks, and says so. - */ + /** True when the CLI created the token as read-only; otherwise only studio's own statement checks apply. */ tokenReadOnly?: boolean; port?: number; /** Opens the studio URL in a browser; omit to only print it. */ @@ -31,25 +27,14 @@ export interface StudioOptions { }; } -// Hostnames that are safe to accept on a loopback-bound server. Requests with -// any other Host header are rejected to defend against DNS-rebinding attacks -// where a browser resolves an attacker-controlled name to 127.0.0.1. +// Any other Host header could be a DNS-rebinding attack resolving to 127.0.0.1. const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); -const isAllowedHost = (hostHeader: string | null): boolean => { - if (!hostHeader) return false; - // Strip an optional :port suffix. IPv6 hosts in Host headers are bracketed - // (e.g. "[::1]:4488") so the trailing :port is unambiguous. - const hostOnly = hostHeader.replace(/:\d+$/, ""); - return LOOPBACK_HOSTS.has(hostOnly); -}; +const isAllowedHost = (host: string | null) => + host !== null && LOOPBACK_HOSTS.has(host.replace(/:\d+$/, "")); const AUTH_COOKIE = "bunny_studio_auth"; -/** Studio-only endpoints live outside `/api`, where every path segment is a table name. */ -const SCHEMA_PATH = "/studio-api/schema"; -const QUERY_PATH = "/studio-api/query"; - const jsonResponse = ( body: unknown, status = 200, @@ -60,11 +45,13 @@ const jsonResponse = ( headers: { "Content-Type": "application/json", ...headers }, }); +const errorText = (err: unknown) => + err instanceof Error ? (err.stack ?? err.message) : String(err); + const safeEqual = (a: string, b: string): boolean => { const ab = Buffer.from(a); const bb = Buffer.from(b); - if (ab.length !== bb.length) return false; - return timingSafeEqual(ab, bb); + return ab.length === bb.length && timingSafeEqual(ab, bb); }; const handleAuth = async ( @@ -74,21 +61,11 @@ const handleAuth = async ( if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } - let body: unknown; - try { - body = await req.json(); - } catch { - return new Response(JSON.stringify({ message: "Invalid JSON body" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - const provided = (body as { token?: unknown })?.token; - if (typeof provided !== "string" || !safeEqual(provided, sessionToken)) { - return new Response(JSON.stringify({ message: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); + const body = (await req.json().catch(() => null)) as { + token?: unknown; + } | null; + if (typeof body?.token !== "string" || !safeEqual(body.token, sessionToken)) { + return jsonResponse({ message: "Unauthorized" }, 401); } return new Response(null, { status: 204, @@ -113,23 +90,17 @@ export async function startStudio(options: StudioOptions): Promise { const distDir = join(import.meta.dir, "..", "dist", "client"); const schema = await introspect({ client }); - const executor = createExecutor({ client }); - // Random per-startup token. The auto-opened browser URL carries it once as - // ?token=…; the client posts it to /api/auth which then sets an HttpOnly - // cookie that gates every subsequent /api/* request. + // Sent once in the opened URL; the client trades it at /api/auth for an HttpOnly cookie. const sessionToken = randomBytes(32).toString("hex"); - const handleRest = requireAuth( - createRestHandler(executor, schema, { + const guard = (handler: (req: Request) => Promise) => + requireAuth(handler, { token: sessionToken, cookieName: AUTH_COOKIE }); + + const handleRest = guard( + createRestHandler(createExecutor({ client }), schema, { basePath: "/api", - onError: (err) => - logger.error( - `API error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, - ), + onError: (err) => logger.error(`API error: ${errorText(err)}`), }), - { token: sessionToken, cookieName: AUTH_COOKIE }, ); - // The full introspection (defaults, key order, index columns), which the - // OpenAPI spec at /api/ only partly carries. const tables = Object.values(schema.tables).sort((a, b) => a.name.localeCompare(b.name), ); @@ -137,43 +108,37 @@ export async function startStudio(options: StudioOptions): Promise { client, new Set(tables.map((t) => t.name)), ); - const handleSchema = requireAuth( - async () => - jsonResponse({ - label: label ?? null, - tokenReadOnly, - tables, - ddl, - views, - }), - { token: sessionToken, cookieName: AUTH_COOKIE }, - ); - const handleQuery = requireAuth( - async (req) => { - if (req.method !== "POST") { - return jsonResponse({ message: "Method not allowed" }, 405, { - Allow: "POST", - }); - } - const body = (await req.json().catch(() => null)) as { - sql?: unknown; - } | null; - if (typeof body?.sql !== "string") { - return jsonResponse({ message: "Expected { sql: string }" }, 400); - } - try { - return jsonResponse(await runReadOnlyQuery(client, body.sql)); - } catch (err) { - const message = queryErrorMessage(err); - if (message) return jsonResponse({ message }, 400); - logger.error( - `Query error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, - ); - return jsonResponse({ message: "Internal error" }, 500); - } - }, - { token: sessionToken, cookieName: AUTH_COOKIE }, + const handleSchema = guard(async () => + jsonResponse({ label: label ?? null, tokenReadOnly, tables, ddl, views }), ); + const handleQuery = guard(async (req) => { + if (req.method !== "POST") { + return jsonResponse({ message: "Method not allowed" }, 405, { + Allow: "POST", + }); + } + const body = (await req.json().catch(() => null)) as { + sql?: unknown; + } | null; + if (typeof body?.sql !== "string") { + return jsonResponse({ message: "Expected { sql: string }" }, 400); + } + try { + return jsonResponse(await runReadOnlyQuery(client, body.sql)); + } catch (err) { + const message = queryErrorMessage(err); + if (message) return jsonResponse({ message }, 400); + logger.error(`Query error: ${errorText(err)}`); + return jsonResponse({ message: "Internal error" }, 500); + } + }); + + const serveFile = async (path: string) => { + const asset = assets[path]; + if (asset) return new Response(Bun.file(asset)); + const file = Bun.file(join(distDir, path)); + return (await file.exists()) ? new Response(file) : null; + }; let server: ReturnType; try { @@ -181,84 +146,37 @@ export async function startStudio(options: StudioOptions): Promise { port, hostname: "127.0.0.1", async fetch(req) { - // Reject requests whose Host header isn't a loopback hostname. - // Protects against DNS-rebinding even if the server is reachable via - // a non-loopback address (e.g. when the caller overrides hostname). if (!isAllowedHost(req.headers.get("host"))) { return new Response("Forbidden", { status: 403 }); } - const url = new URL(req.url); - const pathname = url.pathname; + const { pathname } = new URL(req.url); - if (pathname === SCHEMA_PATH) { - return await handleSchema(req); - } - if (pathname === QUERY_PATH) { - return await handleQuery(req); - } - - // API routes - delegate to REST handler + if (pathname === "/studio-api/schema") return handleSchema(req); + if (pathname === "/studio-api/query") return handleQuery(req); + if (pathname === "/api/auth") return handleAuth(req, sessionToken); if (pathname.startsWith("/api")) { - // /api/auth is the handshake endpoint: public but only succeeds - // with the correct session token. - if (pathname === "/api/auth") { - return await handleAuth(req, sessionToken); - } - // Studio is read-only: the REST handler also serves writes, so - // refuse them here rather than relying on the token's scope. + // The REST handler also serves writes, so refuse them regardless of the token's scope. if (req.method !== "GET") { return jsonResponse({ message: "Studio is read-only" }, 405, { Allow: "GET", }); } - try { - return await handleRest(req); - } catch (err: unknown) { - // Detail goes to the terminal running the studio, not to the browser. - logger.error( - `API error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, - ); - return new Response(JSON.stringify({ message: "Internal error" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } + return handleRest(req); } if (!dev) { - const lookup = pathname === "/" ? "/index.html" : pathname; - - // Try embedded manifest first (works in compiled binary) - const assetPath = assets[lookup]; - if (assetPath) return new Response(Bun.file(assetPath)); - - // Fall back to filesystem (works in source mode without manifest) - try { - const filePath = join(distDir, lookup); - const file = Bun.file(filePath); - if (await file.exists()) return new Response(file); - } catch { - // fall through - } - - // SPA fallback - serve index.html - const indexAsset = assets["/index.html"]; - if (indexAsset) return new Response(Bun.file(indexAsset)); - try { - const indexFile = Bun.file(join(distDir, "index.html")); - if (await indexFile.exists()) return new Response(indexFile); - } catch { - // fall through - } + const file = + (await serveFile(pathname === "/" ? "/index.html" : pathname)) ?? + (await serveFile("/index.html")); + if (file) return file; } return new Response("Not Found", { status: 404 }); }, }); } catch (err: unknown) { - const code = (err as { code?: string })?.code; - if (code === "EADDRINUSE") { + if ((err as { code?: string })?.code === "EADDRINUSE") { throw new Error( `Port ${port} is already in use. Try a different port with --port .`, ); @@ -266,7 +184,6 @@ export async function startStudio(options: StudioOptions): Promise { throw err; } - // In dev mode, spawn Vite dev server - it proxies /api back to this server let viteProc: ReturnType | undefined; let browserUrl: string; @@ -276,7 +193,6 @@ export async function startStudio(options: StudioOptions): Promise { stdout: "inherit", stderr: "inherit", }); - // Give Vite a moment to bind its port await new Promise((r) => setTimeout(r, 1000)); browserUrl = `http://localhost:5173/?token=${sessionToken}`; logger.log(`Studio API running at http://localhost:${server.port}`); @@ -288,7 +204,7 @@ export async function startStudio(options: StudioOptions): Promise { } if (open) { - // Best effort: a missing opener (headless Linux) must not take the server down. + // A missing opener (headless Linux) must not take the server down. try { open(browserUrl); } catch { @@ -296,17 +212,13 @@ export async function startStudio(options: StudioOptions): Promise { } } - // Keep the process alive until interrupted await new Promise((resolve) => { - process.on("SIGINT", () => { - viteProc?.kill(); - server.stop(); - resolve(); - }); - process.on("SIGTERM", () => { + const stop = () => { viteProc?.kill(); server.stop(); resolve(); - }); + }; + process.on("SIGINT", stop); + process.on("SIGTERM", stop); }); }
[role=checkbox]]:translate-y-[2px]", - className, - )} - {...props} - /> - ); -} - -function TableCell({ className, ...props }: React.ComponentProps<"td">) { - return ( - [role=checkbox]]:translate-y-[2px]", - className, - )} - {...props} - /> - ); -} - -function TableCaption({ - className, - ...props -}: React.ComponentProps<"caption">) { - return ( -
- ); -} - -export { - Table, - TableBody, - TableCaption, - TableCell, - TableFooter, - TableHead, - TableHeader, - TableRow, -}; diff --git a/packages/database-studio/client/src/hooks/use-url-state.ts b/packages/database-studio/client/src/hooks/use-url-state.ts index 93d42423..0086be0b 100644 --- a/packages/database-studio/client/src/hooks/use-url-state.ts +++ b/packages/database-studio/client/src/hooks/use-url-state.ts @@ -1,4 +1,4 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useSyncExternalStore } from "react"; function getSearch() { return window.location.search; @@ -15,8 +15,15 @@ export function useUrlParam(key: string): string | null { return new URLSearchParams(search).get(key); } -/** Update URL search params without a page reload. */ -export function setUrlParams(updates: Record) { +/** + * Update URL search params without a page reload. `push` adds a history + * entry so Back returns to the previous view (used for navigation, not for + * every filter keystroke). + */ +export function setUrlParams( + updates: Record, + { push = false }: { push?: boolean } = {}, +) { const params = new URLSearchParams(window.location.search); for (const [key, value] of Object.entries(updates)) { if (value === null || value === "") { @@ -26,13 +33,23 @@ export function setUrlParams(updates: Record) { } } const search = params.toString(); + if (`?${search}` === window.location.search) return; const url = search ? `?${search}` : window.location.pathname; - window.history.replaceState(null, "", url); + if (push) { + window.history.pushState(null, "", url); + } else { + window.history.replaceState(null, "", url); + } // Notify subscribers window.dispatchEvent(new PopStateEvent("popstate")); } -/** Hook that returns a setter for URL params. */ -export function useSetUrlParams() { - return useCallback(setUrlParams, []); -} +/** Params scoped to one table's view; cleared when switching tables. */ +export const TABLE_VIEW_PARAMS = { + view: null, + page: null, + sort: null, + order: null, + filters: null, + mode: null, +} as const; diff --git a/packages/database-studio/client/src/lib/filters.ts b/packages/database-studio/client/src/lib/filters.ts new file mode 100644 index 00000000..5eddf02b --- /dev/null +++ b/packages/database-studio/client/src/lib/filters.ts @@ -0,0 +1,92 @@ +export type FilterMode = "and" | "or"; + +export interface FilterCondition { + column: string; + operator: OperatorId; + value: string; +} + +interface Operator { + label: string; + /** Operators like IS NULL take no value. */ + nullary?: boolean; + /** The `op.value` part of a REST filter (`not.` prefix included). */ + encode: (value: string) => string; +} + +// LIKE patterns: `%` and `_` in the user's value stay wildcards, which is +// what people typing into a "contains" box usually expect anyway. +export const OPERATORS = { + eq: { label: "equals", encode: (v) => `eq.${v}` }, + neq: { label: "not equal", encode: (v) => `neq.${v}` }, + contains: { label: "contains", encode: (v) => `ilike.%${v}%` }, + not_contains: { label: "doesn't contain", encode: (v) => `not.ilike.%${v}%` }, + starts: { label: "starts with", encode: (v) => `ilike.${v}%` }, + ends: { label: "ends with", encode: (v) => `ilike.%${v}` }, + gt: { label: ">", encode: (v) => `gt.${v}` }, + gte: { label: "≥", encode: (v) => `gte.${v}` }, + lt: { label: "<", encode: (v) => `lt.${v}` }, + lte: { label: "≤", encode: (v) => `lte.${v}` }, + in: { + label: "is one of", + encode: (v) => + `in.(${v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .join(",")})`, + }, + like: { label: "LIKE", encode: (v) => `like.${v}` }, + not_like: { label: "NOT LIKE", encode: (v) => `not.like.${v}` }, + null: { label: "is NULL", nullary: true, encode: () => "is.null" }, + not_null: { + label: "is not NULL", + nullary: true, + encode: () => "not.is.null", + }, +} satisfies Record; + +export type OperatorId = keyof typeof OPERATORS; + +export const OPERATOR_IDS = Object.keys(OPERATORS) as OperatorId[]; + +export function isOperator(id: unknown): id is OperatorId { + return typeof id === "string" && Object.hasOwn(OPERATORS, id); +} + +export function isNullary(id: OperatorId): boolean { + return "nullary" in OPERATORS[id]; +} + +/** A filter is ready to send once it has a value, unless its operator needs none. */ +export function isComplete(f: FilterCondition): boolean { + return isNullary(f.operator) || f.value !== ""; +} + +/** Quote a value for an `or=(...)` group when it carries characters the group syntax uses. */ +function quoteForGroup(encoded: string): string { + const dot = encoded.startsWith("not.") + ? encoded.indexOf(".", 4) + : encoded.indexOf("."); + const op = encoded.slice(0, dot + 1); + const value = encoded.slice(dot + 1); + // `in.(…)` is already parenthesised and parsed as a list. + if (op.endsWith("in.") || !/[,()"\\]/.test(value)) return encoded; + return `${op}"${value.replace(/[\\"]/g, "\\$&")}"`; +} + +/** Query parameters for the REST handler: one per filter, or a single `or=(...)` group. */ +export function filterParams( + filters: FilterCondition[], + mode: FilterMode, +): [string, string][] { + const ready = filters.filter(isComplete); + if (mode === "or" && ready.length > 1) { + const parts = ready.map( + (f) => + `${f.column}.${quoteForGroup(OPERATORS[f.operator].encode(f.value))}`, + ); + return [["or", `(${parts.join(",")})`]]; + } + return ready.map((f) => [f.column, OPERATORS[f.operator].encode(f.value)]); +} diff --git a/packages/database-studio/client/src/lib/format.ts b/packages/database-studio/client/src/lib/format.ts new file mode 100644 index 00000000..c49092f8 --- /dev/null +++ b/packages/database-studio/client/src/lib/format.ts @@ -0,0 +1,55 @@ +import type { Row } from "@/api"; + +/** Plain-text form of a cell, as copied to the clipboard or exported. */ +export function toText(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/** Parse a TEXT value that holds a JSON object or array, for pretty-printing. */ +export function parseJsonObject(value: unknown): object | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!/^[[{]/.test(trimmed)) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** BLOBs arrive base64-encoded; this is their decoded size. */ +export function base64Size(value: string): number { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return Math.floor((value.length * 3) / 4) - padding; +} + +function csvField(value: unknown): string { + const text = toText(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +export function toCsv(columns: string[], rows: Row[]): string { + const lines = [columns.map(csvField).join(",")]; + for (const row of rows) { + lines.push(columns.map((c) => csvField(row[c])).join(",")); + } + return `${lines.join("\r\n")}\r\n`; +} + +export function download(filename: string, contents: string, type: string) { + const url = URL.createObjectURL(new Blob([contents], { type })); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/packages/database-studio/client/src/main.tsx b/packages/database-studio/client/src/main.tsx index 537e97a0..31a8695f 100644 --- a/packages/database-studio/client/src/main.tsx +++ b/packages/database-studio/client/src/main.tsx @@ -16,13 +16,15 @@ function exchangeToken(): Promise { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), credentials: "same-origin", - }).finally(() => { - params.delete("token"); - const qs = params.toString(); - const newUrl = - window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash; - window.history.replaceState({}, "", newUrl); - }); + }) + .then(() => undefined) + .finally(() => { + params.delete("token"); + const qs = params.toString(); + const newUrl = + window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash; + window.history.replaceState({}, "", newUrl); + }); } const rootEl = document.getElementById("root"); diff --git a/packages/database-studio/client/tsconfig.json b/packages/database-studio/client/tsconfig.json index 61671cef..6ff72ba0 100644 --- a/packages/database-studio/client/tsconfig.json +++ b/packages/database-studio/client/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/packages/database-studio/client/vite.config.ts b/packages/database-studio/client/vite.config.ts index f569f8bd..a5d164ef 100644 --- a/packages/database-studio/client/vite.config.ts +++ b/packages/database-studio/client/vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ server: { proxy: { "/api": "http://localhost:4488", + "/studio-api": "http://localhost:4488", }, }, }); diff --git a/packages/database-studio/package.json b/packages/database-studio/package.json index 23240081..46424cdb 100644 --- a/packages/database-studio/package.json +++ b/packages/database-studio/package.json @@ -10,7 +10,8 @@ "scripts": { "dev:client": "cd client && bunx --bun vite", "dev:server": "bun src/dev-server.ts", - "build:client": "cd client && bunx --bun vite build --outDir ../dist/client" + "build:client": "cd client && bunx --bun vite build --outDir ../dist/client", + "typecheck:client": "cd client && bunx tsc --noEmit -p ." }, "dependencies": { "@bunny.net/database-adapter": "workspace:*", @@ -21,11 +22,9 @@ "devDependencies": { "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.0.0", - "@tanstack/react-table": "^8.21.3", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/packages/database-studio/src/server.ts b/packages/database-studio/src/server.ts index 8bfdbf9b..a254d3de 100644 --- a/packages/database-studio/src/server.ts +++ b/packages/database-studio/src/server.ts @@ -7,6 +7,8 @@ import { assets } from "./client-manifest.ts"; export interface StudioOptions { client: Database; + /** Shown in the studio header so it's clear which database is open (e.g. its ID and host). */ + label?: string; port?: number; open?: boolean; dev?: boolean; @@ -31,6 +33,37 @@ const isAllowedHost = (hostHeader: string | null): boolean => { const AUTH_COOKIE = "bunny_studio_auth"; +/** Studio-only endpoints live outside `/api`, where every path segment is a table name. */ +const SCHEMA_PATH = "/studio-api/schema"; + +const jsonResponse = ( + body: unknown, + status = 200, + headers?: Record, +): Response => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); + +const BROWSER_OPENERS: Record string[]> = { + darwin: (url) => ["open", url], + linux: (url) => ["xdg-open", url], + win32: (url) => ["rundll32", "url.dll,FileProtocolHandler", url], +}; + +/** Best effort: a missing opener (headless Linux, unknown platform) must not take the server down. */ +const openBrowser = (url: string): boolean => { + const opener = BROWSER_OPENERS[process.platform]; + if (!opener) return false; + try { + Bun.spawn(opener(url), { stdio: ["ignore", "ignore", "ignore"] }); + return true; + } catch { + return false; + } +}; + const safeEqual = (a: string, b: string): boolean => { const ab = Buffer.from(a); const bb = Buffer.from(b); @@ -72,6 +105,7 @@ const handleAuth = async ( export async function startStudio(options: StudioOptions): Promise { const { client, + label, port = 4488, open = true, dev = false, @@ -97,6 +131,15 @@ export async function startStudio(options: StudioOptions): Promise { }), { token: sessionToken, cookieName: AUTH_COOKIE }, ); + // The full introspection (defaults, key order, index columns), which the + // OpenAPI spec at /api/ only partly carries. + const tables = Object.values(schema.tables).sort((a, b) => + a.name.localeCompare(b.name), + ); + const handleSchema = requireAuth( + async () => jsonResponse({ label: label ?? null, readOnly: true, tables }), + { token: sessionToken, cookieName: AUTH_COOKIE }, + ); let server: ReturnType; try { @@ -114,6 +157,10 @@ export async function startStudio(options: StudioOptions): Promise { const url = new URL(req.url); const pathname = url.pathname; + if (pathname === SCHEMA_PATH) { + return await handleSchema(req); + } + // API routes - delegate to REST handler if (pathname.startsWith("/api")) { // /api/auth is the handshake endpoint: public but only succeeds @@ -121,6 +168,13 @@ export async function startStudio(options: StudioOptions): Promise { if (pathname === "/api/auth") { return await handleAuth(req, sessionToken); } + // Studio is read-only: the REST handler also serves writes, so + // refuse them here rather than relying on the token's scope. + if (req.method !== "GET") { + return jsonResponse({ message: "Studio is read-only" }, 405, { + Allow: "GET", + }); + } try { return await handleRest(req); } catch (err: unknown) { @@ -196,14 +250,8 @@ export async function startStudio(options: StudioOptions): Promise { logger.log("Press Ctrl+C to stop."); } - if (open) { - const proc = Bun.spawn( - process.platform === "darwin" - ? ["open", browserUrl] - : ["xdg-open", browserUrl], - { stdout: "ignore", stderr: "ignore" }, - ); - await proc.exited; + if (open && !openBrowser(browserUrl)) { + logger.log("Couldn't open a browser. Open the URL above to continue."); } // Keep the process alive until interrupted From 0865af0e560f886cb3d9b6a3416f66d3d9df2f22 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 14:13:20 +0000 Subject: [PATCH 2/7] feat(db): bring back the top tab bar in db studio --- .changeset/studio-read-only.md | 2 +- packages/cli/README.md | 2 +- packages/database-studio/client/src/App.tsx | 109 ++++++++++-- .../client/src/components/Sidebar.tsx | 159 ------------------ .../client/src/components/TabBar.tsx | 143 ++++++++++++++++ .../client/src/components/TablePicker.tsx | 100 +++++++++++ .../client/src/components/TableView.tsx | 3 - 7 files changed, 337 insertions(+), 181 deletions(-) delete mode 100644 packages/database-studio/client/src/components/Sidebar.tsx create mode 100644 packages/database-studio/client/src/components/TabBar.tsx create mode 100644 packages/database-studio/client/src/components/TablePicker.tsx diff --git a/.changeset/studio-read-only.md b/.changeset/studio-read-only.md index 548318e0..a8a068ef 100644 --- a/.changeset/studio-read-only.md +++ b/.changeset/studio-read-only.md @@ -6,4 +6,4 @@ "@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. The UI gets a searchable table sidebar, 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. +`bunny db studio` is now a read-only explorer: it mints a read-only token and the local server refuses writes. The UI 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. diff --git a/packages/cli/README.md b/packages/cli/README.md index 91c6a90a..d19a85c7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -375,7 +375,7 @@ bunny db shell --url libsql://01KCHBG8C5KSFGG0VRNFQ7EK7X-my-app.lite.bunnydb.net 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. -Browse tables from the sidebar (⌘K / Ctrl K to search), sort by any column, and 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, and incoming foreign keys. Export the current page as CSV or JSON. Filters, sorting, and paging live in the URL, so Back works and links can be reopened while studio is running. +Open tables as tabs across the top (⌘K / Ctrl K to search for one); each tab keeps its own filters, sort, and page. Sort by any column and 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, and incoming foreign keys. Export the current page as CSV or JSON. 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) diff --git a/packages/database-studio/client/src/App.tsx b/packages/database-studio/client/src/App.tsx index 30d182b6..c991f36a 100644 --- a/packages/database-studio/client/src/App.tsx +++ b/packages/database-studio/client/src/App.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { fetchSchema, type StudioSchema } from "@/api"; import { LoadingScreen } from "@/components/LoadingScreen"; import { Overview } from "@/components/Overview"; -import { Sidebar } from "@/components/Sidebar"; +import { TabBar } from "@/components/TabBar"; import { TableView } from "@/components/TableView"; import { setUrlParams, @@ -13,10 +13,24 @@ import type { FilterCondition } from "@/lib/filters"; export type OpenTable = (name: string, filters?: FilterCondition[]) => void; +type ViewState = Record; + +/** The active tab's view params as they stand in the URL. */ +function currentViewState(): ViewState { + const params = new URLSearchParams(window.location.search); + return Object.fromEntries( + Object.keys(TABLE_VIEW_PARAMS).map((k) => [k, params.get(k)]), + ) as ViewState; +} + export function App() { const [schema, setSchema] = useState(null); const [error, setError] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); const activeName = useUrlParam("table"); + const openParam = useUrlParam("open"); + // Each tab keeps its filters, sort, and page while another tab is active. + const tabState = useRef(new Map()); useEffect(() => { fetchSchema() @@ -24,15 +38,71 @@ export function App() { .catch((e) => setError(e instanceof Error ? e.message : String(e))); }, []); - const openTable = useCallback((name, filters) => { - setUrlParams( - { - table: name, - ...TABLE_VIEW_PARAMS, - filters: filters?.length ? JSON.stringify(filters) : null, - }, - { push: true }, - ); + const known = new Set(schema?.tables.map((t) => t.name)); + const openTabs = (openParam?.split(",") ?? []).filter((n) => known.has(n)); + if (activeName && known.has(activeName) && !openTabs.includes(activeName)) { + openTabs.push(activeName); + } + const openKey = openTabs.join(","); + + const switchTo = useCallback( + ( + name: string | null, + opts: { filters?: FilterCondition[]; tabs?: string[] } = {}, + ) => { + const current = new URLSearchParams(window.location.search).get("table"); + if (current) tabState.current.set(current, currentViewState()); + + const tabs = opts.tabs ?? openKey.split(",").filter(Boolean); + if (name && !tabs.includes(name)) tabs.push(name); + + const view: Record = opts.filters + ? { + ...TABLE_VIEW_PARAMS, + filters: opts.filters.length ? JSON.stringify(opts.filters) : null, + } + : ((name ? tabState.current.get(name) : undefined) ?? + TABLE_VIEW_PARAMS); + + setUrlParams( + { open: tabs.length ? tabs.join(",") : null, table: name, ...view }, + { push: true }, + ); + setPickerOpen(false); + }, + [openKey], + ); + + const openTable = useCallback( + (name, filters) => switchTo(name, { filters }), + [switchTo], + ); + + function closeTab(name: string) { + tabState.current.delete(name); + const tabs = openTabs.filter((t) => t !== name); + if (name !== activeName) { + setUrlParams({ open: tabs.length ? tabs.join(",") : null }); + return; + } + // Closing the active tab moves to its neighbour, like a browser. + const index = openTabs.indexOf(name); + const next = tabs[Math.min(index, tabs.length - 1)] ?? null; + // The closed tab's state must not be saved on the way out. + setUrlParams({ table: null }); + switchTo(next, { tabs }); + } + + // Cmd/Ctrl+K opens the table picker from anywhere. + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPickerOpen((open) => !open); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); }, []); useEffect(() => { @@ -50,13 +120,18 @@ export function App() { const active = schema.tables.find((t) => t.name === activeName) ?? null; return ( -
- + openTable(name)} + openTabs={openTabs} + activeTab={active?.name ?? null} + pickerOpen={pickerOpen} + onPickerOpenChange={setPickerOpen} + onSelect={(name) => switchTo(name)} + onClose={closeTab} + onHome={() => switchTo(null)} /> -
+
{active ? ( openTable(name)} + onSelect={(name) => switchTo(name)} /> )}
diff --git a/packages/database-studio/client/src/components/Sidebar.tsx b/packages/database-studio/client/src/components/Sidebar.tsx deleted file mode 100644 index 503f4ff3..00000000 --- a/packages/database-studio/client/src/components/Sidebar.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { Lock, Search, Table2 } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { StudioSchema } from "@/api"; -import { DatabaseIcon } from "@/components/DatabaseIcon"; -import { cn } from "@/lib/utils"; - -const isMac = /Mac|iPhone|iPad/.test(navigator.platform); - -export function Sidebar({ - schema, - activeTable, - onSelect, -}: { - schema: StudioSchema; - activeTable: string | null; - onSelect: (name: string) => void; -}) { - const [search, setSearch] = useState(""); - const [highlight, setHighlight] = useState(0); - const inputRef = useRef(null); - const listRef = useRef(null); - - const visible = useMemo(() => { - const q = search.trim().toLowerCase(); - return q - ? schema.tables.filter((t) => t.name.toLowerCase().includes(q)) - : schema.tables; - }, [schema.tables, search]); - - // Cmd/Ctrl+K jumps to the table search from anywhere. - useEffect(() => { - function onKeyDown(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { - e.preventDefault(); - inputRef.current?.focus(); - inputRef.current?.select(); - } - } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - useEffect(() => { - listRef.current - ?.querySelector(`[data-index="${highlight}"]`) - ?.scrollIntoView({ block: "nearest" }); - }, [highlight]); - - function onSearchKeyDown(e: React.KeyboardEvent) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setHighlight((h) => Math.min(h + 1, visible.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setHighlight((h) => Math.max(h - 1, 0)); - } else if (e.key === "Enter") { - const table = visible[highlight]; - if (table) onSelect(table.name); - } else if (e.key === "Escape") { - setSearch(""); - inputRef.current?.blur(); - } - } - - return ( - - ); -} diff --git a/packages/database-studio/client/src/components/TabBar.tsx b/packages/database-studio/client/src/components/TabBar.tsx new file mode 100644 index 00000000..3ec63086 --- /dev/null +++ b/packages/database-studio/client/src/components/TabBar.tsx @@ -0,0 +1,143 @@ +import { Lock, Plus, X } from "lucide-react"; +import { useEffect, useRef } from "react"; +import type { StudioSchema } from "@/api"; +import { DatabaseIcon } from "@/components/DatabaseIcon"; +import { Popover } from "@/components/Popover"; +import { TablePicker } from "@/components/TablePicker"; +import { cn } from "@/lib/utils"; + +const isMac = /Mac|iPhone|iPad/.test(navigator.platform); + +export function TabBar({ + schema, + openTabs, + activeTab, + pickerOpen, + onPickerOpenChange, + onSelect, + onClose, + onHome, +}: { + schema: StudioSchema; + openTabs: string[]; + activeTab: string | null; + pickerOpen: boolean; + onPickerOpenChange: (open: boolean) => void; + onSelect: (name: string) => void; + onClose: (name: string) => void; + onHome: () => void; +}) { + const navRef = useRef(null); + + useEffect(() => { + if (!activeTab) return; + navRef.current + ?.querySelector(`[data-tab="${CSS.escape(activeTab)}"]`) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeTab]); + + return ( +
+ + + + +
+ + onPickerOpenChange(false)}> + + +
+ +
+ {schema.label && ( + + {schema.label} + + )} + + + Read-only + +
+
+ ); +} diff --git a/packages/database-studio/client/src/components/TablePicker.tsx b/packages/database-studio/client/src/components/TablePicker.tsx new file mode 100644 index 00000000..e0dbca21 --- /dev/null +++ b/packages/database-studio/client/src/components/TablePicker.tsx @@ -0,0 +1,100 @@ +import { Search, Table2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { TableDefinition } from "@/api"; +import { cn } from "@/lib/utils"; + +/** Searchable table list with arrow-key navigation, shown in the tab bar's "+" popover. */ +export function TablePicker({ + tables, + openTabs, + onSelect, +}: { + tables: TableDefinition[]; + openTabs: string[]; + onSelect: (name: string) => void; +}) { + const [search, setSearch] = useState(""); + const [highlight, setHighlight] = useState(0); + const listRef = useRef(null); + + const visible = useMemo(() => { + const q = search.trim().toLowerCase(); + return q ? tables.filter((t) => t.name.toLowerCase().includes(q)) : tables; + }, [tables, search]); + + useEffect(() => { + listRef.current + ?.querySelector(`[data-index="${highlight}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [highlight]); + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlight((h) => Math.min(h + 1, visible.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlight((h) => Math.max(h - 1, 0)); + } else if (e.key === "Enter") { + const table = visible[highlight]; + if (table) onSelect(table.name); + } + } + + return ( +
+
+ + { + setSearch(e.target.value); + setHighlight(0); + }} + onKeyDown={onKeyDown} + placeholder="Open a table…" + aria-label="Search tables" + // biome-ignore lint/a11y/noAutofocus: the picker exists to be typed into + autoFocus + className="h-8 w-full rounded-md border bg-background pl-7 pr-2 text-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" + /> +
+
+ {visible.map((table, i) => ( + + ))} + {visible.length === 0 && ( +

+ {tables.length === 0 + ? "No tables yet" + : `No tables match “${search}”`} +

+ )} +
+
+ ); +} diff --git a/packages/database-studio/client/src/components/TableView.tsx b/packages/database-studio/client/src/components/TableView.tsx index 7d381af5..c7d370f7 100644 --- a/packages/database-studio/client/src/components/TableView.tsx +++ b/packages/database-studio/client/src/components/TableView.tsx @@ -233,9 +233,6 @@ export function TableView({ return (
-

- {table.name} -

{(["data", "schema"] as const).map((v) => (
); } + +function Count({ n }: { n: number }) { + return ( +
+ {n === 0 ? 0 : n} +
onSelect(t.name)}>Browse diff --git a/packages/database-studio/client/src/components/Pager.tsx b/packages/database-studio/client/src/components/Pager.tsx new file mode 100644 index 00000000..bcf0eca4 --- /dev/null +++ b/packages/database-studio/client/src/components/Pager.tsx @@ -0,0 +1,119 @@ +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; +import type { RowsResponse } from "@/api"; +import { Button } from "@/components/ui/button"; + +export const PAGE_SIZES = [25, 50, 100, 250]; +export const DEFAULT_PAGE_SIZE = 50; + +/** The table view's footer: which rows are showing, the page size, and page buttons. */ +export function Pager({ + data, + page, + limit, + onPage, + onLimit, +}: { + data: RowsResponse | null; + page: number; + limit: number; + onPage: (page: number) => void; + onLimit: (limit: number) => void; +}) { + const offset = (page - 1) * limit; + return ( +
+ {data && ( + + {data.totalRows === 0 + ? "0 rows" + : `${(offset + 1).toLocaleString()}–${(offset + data.rows.length).toLocaleString()} of ${data.totalRows.toLocaleString()} row${data.totalRows === 1 ? "" : "s"}`} + + {data.responseTime} ms + + + )} +
+ + {data && ( +
+ onPage(1)} + > + + + onPage(page - 1)} + > + + + + Page {page.toLocaleString()} of {data.totalPages.toLocaleString()} + + = data.totalPages} + onClick={() => onPage(page + 1)} + > + + + = data.totalPages} + onClick={() => onPage(data.totalPages)} + > + + +
+ )} +
+
+ ); +} + +function PagerButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/packages/database-studio/client/src/components/RowSheet.tsx b/packages/database-studio/client/src/components/RowSheet.tsx index 8ed6a989..60185eca 100644 --- a/packages/database-studio/client/src/components/RowSheet.tsx +++ b/packages/database-studio/client/src/components/RowSheet.tsx @@ -19,6 +19,8 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { toText } from "@/lib/format"; +import { useCopy } from "@/lib/interaction"; +import { incomingReferences } from "@/lib/schema"; import { cn } from "@/lib/utils"; export interface SheetEntry { @@ -26,19 +28,6 @@ export interface SheetEntry { row: Row; } -function useCopy() { - const [copied, setCopied] = useState(null); - return { - copied, - copy(key: string, text: string) { - navigator.clipboard.writeText(text).then(() => { - setCopied(key); - setTimeout(() => setCopied((c) => (c === key ? null : c)), 1200); - }); - }, - }; -} - function rowLabel({ table, row }: SheetEntry): string { if (table.primaryKey.length === 0) return table.name; const key = table.primaryKey.map((c) => toText(row[c])).join(", "); @@ -68,17 +57,7 @@ export function RowSheet({ const { copied, copy } = useCopy(); const [followErrors, setFollowErrors] = useState>({}); - const incoming = entry - ? tables.flatMap((t) => - t.foreignKeys - .filter((fk) => fk.referencesTable === entry.table.name) - .map((fk) => ({ - table: t.name, - column: fk.column, - to: fk.referencesColumn, - })), - ) - : []; + const incoming = entry ? incomingReferences(tables, entry.table.name) : []; return (
{incoming.map((ref) => { - const value = entry.row[ref.to]; + const value = entry.row[ref.referencesColumn]; if (value === null || value === undefined) return null; return (
-
- {data && ( - - {data.totalRows === 0 - ? "0 rows" - : `${(offset + 1).toLocaleString()}–${(offset + data.rows.length).toLocaleString()} of ${data.totalRows.toLocaleString()} row${data.totalRows === 1 ? "" : "s"}`} - - {data.responseTime} ms - - - )} -
- - {data && ( -
- setPage(1)} - > - - - setPage(page - 1)} - > - - - - Page {page.toLocaleString()} of{" "} - {data.totalPages.toLocaleString()} - - = data.totalPages} - onClick={() => setPage(page + 1)} - > - - - = data.totalPages} - onClick={() => setPage(data.totalPages)} - > - - -
- )} -
-
+ + setUrlParams({ + limit: n === DEFAULT_PAGE_SIZE ? null : String(n), + page: null, + }) + } + /> )} @@ -564,29 +468,3 @@ export function TableView({
); } - -function PagerButton({ - label, - disabled, - onClick, - children, -}: { - label: string; - disabled: boolean; - onClick: () => void; - children: React.ReactNode; -}) { - return ( - - ); -} diff --git a/packages/database-studio/client/src/hooks/use-table-rows.ts b/packages/database-studio/client/src/hooks/use-table-rows.ts new file mode 100644 index 00000000..9f756f7a --- /dev/null +++ b/packages/database-studio/client/src/hooks/use-table-rows.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { fetchRows, type RowsQuery, type RowsResponse } from "@/api"; + +/** + * One page of a table's rows. Refetches when the query changes, cancels a + * fetch that a newer one replaces, and keeps the last page on screen while + * the next loads. + */ +export function useTableRows(table: string, query: RowsQuery) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + // Compare by value so a re-render with an equal query doesn't refetch. + const queryKey = JSON.stringify(query); + const stableQuery = useMemo( + () => JSON.parse(queryKey) as RowsQuery, + [queryKey], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey forces a refetch + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + fetchRows(table, stableQuery, controller.signal) + .then((d) => { + setData(d); + setError(null); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [table, stableQuery, reloadKey]); + + const refresh = useCallback(() => setReloadKey((k) => k + 1), []); + + return { data, loading, error, refresh }; +} diff --git a/packages/database-studio/client/src/lib/interaction.ts b/packages/database-studio/client/src/lib/interaction.ts new file mode 100644 index 00000000..8297e013 --- /dev/null +++ b/packages/database-studio/client/src/lib/interaction.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from "react"; + +export const isMac = /Mac|iPhone|iPad/.test(navigator.platform); + +/** The platform's modifier key as shown in shortcut hints. */ +export const MOD_KEY = isMac ? "⌘" : "Ctrl "; + +/** + * Copy text and remember which thing was copied for a moment, so the caller + * can flash a check mark next to it. + */ +export function useCopy(duration = 1200) { + const [copied, setCopied] = useState(null); + const copy = useCallback( + (key: string, text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(key); + setTimeout(() => setCopied((c) => (c === key ? null : c)), duration); + }); + }, + [duration], + ); + return { copied, copy }; +} + +/** + * Track a pointer drag that started with `e`, reporting how far it has moved. + * Listens on the window so the drag survives leaving the handle. + */ +export function startDrag( + e: React.PointerEvent, + onMove: (dx: number, dy: number) => void, +) { + e.preventDefault(); + e.stopPropagation(); + const { clientX: x, clientY: y } = e; + const move = (ev: PointerEvent) => onMove(ev.clientX - x, ev.clientY - y); + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); +} diff --git a/packages/database-studio/client/src/lib/schema.ts b/packages/database-studio/client/src/lib/schema.ts new file mode 100644 index 00000000..3ea2c92f --- /dev/null +++ b/packages/database-studio/client/src/lib/schema.ts @@ -0,0 +1,25 @@ +import type { TableDefinition } from "@/api"; + +export interface IncomingReference { + /** The table holding the foreign key. */ + table: string; + column: string; + /** The column it points at in the referenced table. */ + referencesColumn: string; +} + +/** Foreign keys in any table that point at `table`. */ +export function incomingReferences( + tables: TableDefinition[], + table: string, +): IncomingReference[] { + return tables.flatMap((t) => + t.foreignKeys + .filter((fk) => fk.referencesTable === table) + .map((fk) => ({ + table: t.name, + column: fk.column, + referencesColumn: fk.referencesColumn, + })), + ); +} diff --git a/packages/database-studio/src/catalog.ts b/packages/database-studio/src/catalog.ts index f382f76c..0283e4cb 100644 --- a/packages/database-studio/src/catalog.ts +++ b/packages/database-studio/src/catalog.ts @@ -1,4 +1,7 @@ -import { DEFAULT_EXCLUDE_PATTERNS } from "@bunny.net/database-adapter"; +import { + DEFAULT_EXCLUDE_PATTERNS, + shouldInclude, +} from "@bunny.net/database-adapter"; import type { Database } from "@bunny.net/database-client"; export interface ViewDefinition { @@ -18,10 +21,7 @@ type CatalogClient = Pick; const quote = (name: string) => `"${name.replace(/"/g, '""')}"`; const excluded = (name: string) => - name.startsWith("sqlite_") || - DEFAULT_EXCLUDE_PATTERNS.some((p) => - p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : name === p, - ); + name.startsWith("sqlite_") || !shouldInclude(name, DEFAULT_EXCLUDE_PATTERNS); /** * A view's columns, or none when SQLite can't resolve it (a view over a diff --git a/packages/database-studio/src/dev-server.ts b/packages/database-studio/src/dev-server.ts index 68246af6..1de1f1cf 100644 --- a/packages/database-studio/src/dev-server.ts +++ b/packages/database-studio/src/dev-server.ts @@ -30,4 +30,4 @@ const client = connect({ headers: { "User-Agent": "bunny-database-studio" }, }); -await startStudio({ client, port: Number(values.port), open: false }); +await startStudio({ client, port: Number(values.port) }); diff --git a/packages/database-studio/src/server.ts b/packages/database-studio/src/server.ts index dde3d9b0..ec13d73a 100644 --- a/packages/database-studio/src/server.ts +++ b/packages/database-studio/src/server.ts @@ -2,7 +2,11 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import { join } from "node:path"; import { createExecutor, introspect } from "@bunny.net/database-adapter"; import type { Database } from "@bunny.net/database-client"; -import { createRestHandler, requireAuth } from "@bunny.net/database-rest"; +import { + createRestHandler, + encodeValue, + requireAuth, +} from "@bunny.net/database-rest"; import { loadCatalog } from "./catalog.ts"; import { assets } from "./client-manifest.ts"; import { queryErrorMessage, runReadOnlyQuery } from "./query.ts"; @@ -18,7 +22,8 @@ export interface StudioOptions { */ tokenReadOnly?: boolean; port?: number; - open?: boolean; + /** Opens the studio URL in a browser; omit to only print it. */ + open?: (url: string) => void; dev?: boolean; logger?: { log(msg: string): void; @@ -45,13 +50,6 @@ const AUTH_COOKIE = "bunny_studio_auth"; const SCHEMA_PATH = "/studio-api/schema"; const QUERY_PATH = "/studio-api/query"; -/** Same encoding as the REST handler: bigint as a decimal string, bytes as base64. */ -const encodeValue = (_key: string, value: unknown): unknown => { - if (typeof value === "bigint") return value.toString(); - if (value instanceof Uint8Array) return Buffer.from(value).toString("base64"); - return value; -}; - const jsonResponse = ( body: unknown, status = 200, @@ -62,24 +60,6 @@ const jsonResponse = ( headers: { "Content-Type": "application/json", ...headers }, }); -const BROWSER_OPENERS: Record string[]> = { - darwin: (url) => ["open", url], - linux: (url) => ["xdg-open", url], - win32: (url) => ["rundll32", "url.dll,FileProtocolHandler", url], -}; - -/** Best effort: a missing opener (headless Linux, unknown platform) must not take the server down. */ -const openBrowser = (url: string): boolean => { - const opener = BROWSER_OPENERS[process.platform]; - if (!opener) return false; - try { - Bun.spawn(opener(url), { stdio: ["ignore", "ignore", "ignore"] }); - return true; - } catch { - return false; - } -}; - const safeEqual = (a: string, b: string): boolean => { const ab = Buffer.from(a); const bb = Buffer.from(b); @@ -124,7 +104,7 @@ export async function startStudio(options: StudioOptions): Promise { label, tokenReadOnly = false, port = 4488, - open = true, + open, dev = false, logger = console, } = options; @@ -161,7 +141,6 @@ export async function startStudio(options: StudioOptions): Promise { async () => jsonResponse({ label: label ?? null, - readOnly: true, tokenReadOnly, tables, ddl, @@ -308,8 +287,13 @@ export async function startStudio(options: StudioOptions): Promise { logger.log("Press Ctrl+C to stop."); } - if (open && !openBrowser(browserUrl)) { - logger.log("Couldn't open a browser. Open the URL above to continue."); + if (open) { + // Best effort: a missing opener (headless Linux) must not take the server down. + try { + open(browserUrl); + } catch { + logger.log("Couldn't open a browser. Open the URL above to continue."); + } } // Keep the process alive until interrupted From b075377e6d4ccb9e83e19dd12e3eab274f5a8746 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 20 Oct 2026 08:36:45 +0100 Subject: [PATCH 7/7] refactorings --- packages/database-studio/client/src/App.tsx | 3 - packages/database-studio/client/src/api.ts | 23 +- .../client/src/components/CellValue.tsx | 4 - .../client/src/components/DataGrid.tsx | 13 +- .../client/src/components/FilterBar.tsx | 34 +-- .../client/src/components/LoadingScreen.tsx | 12 +- .../client/src/components/Overview.tsx | 5 +- .../client/src/components/Pager.tsx | 1 - .../client/src/components/Popover.tsx | 5 +- .../client/src/components/RowSheet.tsx | 1 - .../client/src/components/SchemaTab.tsx | 4 +- .../client/src/components/SqlConsole.tsx | 23 +- .../client/src/components/SqlEditor.tsx | 6 - .../client/src/components/TabBar.tsx | 1 - .../client/src/components/TablePicker.tsx | 1 - .../client/src/components/TableView.tsx | 148 +++++------- .../client/src/components/ui/button.tsx | 2 +- .../client/src/components/ui/select.tsx | 39 +-- .../client/src/components/ui/sheet.tsx | 71 +----- .../client/src/hooks/use-table-rows.ts | 8 +- .../client/src/hooks/use-url-state.ts | 11 +- packages/database-studio/client/src/index.css | 2 +- .../database-studio/client/src/lib/filters.ts | 12 +- .../database-studio/client/src/lib/format.ts | 30 ++- .../client/src/lib/interaction.ts | 16 +- .../database-studio/client/src/lib/schema.ts | 14 +- .../client/src/lib/sql.test.ts | 37 +-- .../database-studio/client/src/lib/sql.ts | 9 +- packages/database-studio/client/src/main.tsx | 4 +- packages/database-studio/src/catalog.ts | 11 +- .../database-studio/src/client-manifest.ts | 4 +- packages/database-studio/src/modules.d.ts | 4 +- packages/database-studio/src/query.test.ts | 77 ++---- packages/database-studio/src/query.ts | 46 +--- packages/database-studio/src/server.ts | 222 ++++++------------ 35 files changed, 248 insertions(+), 655 deletions(-) diff --git a/packages/database-studio/client/src/App.tsx b/packages/database-studio/client/src/App.tsx index 6b86e6dc..decdd4a5 100644 --- a/packages/database-studio/client/src/App.tsx +++ b/packages/database-studio/client/src/App.tsx @@ -18,7 +18,6 @@ export type OpenTable = (name: string, filters?: FilterCondition[]) => void; type ViewState = Record; -/** The active tab's view params as they stand in the URL. */ function currentViewState(): ViewState { const params = new URLSearchParams(window.location.search); return Object.fromEntries( @@ -107,7 +106,6 @@ export function App() { setUrlParams({ open: tabs.length ? tabs.join(",") : null }); return; } - // Closing the active tab moves to its neighbour, like a browser. const index = openTabs.indexOf(name); const next = tabs[Math.min(index, tabs.length - 1)] ?? null; // The closed tab's state must not be saved on the way out. @@ -115,7 +113,6 @@ export function App() { switchTo(next, { tabs }); } - // Cmd/Ctrl+K opens the table picker from anywhere. useEffect(() => { function onKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { diff --git a/packages/database-studio/client/src/api.ts b/packages/database-studio/client/src/api.ts index b93f30a5..464e5f9c 100644 --- a/packages/database-studio/client/src/api.ts +++ b/packages/database-studio/client/src/api.ts @@ -19,12 +19,9 @@ export interface ViewDefinition { } export interface StudioSchema { - /** Which database is open, e.g. `db_… (host)`. */ label: string | null; - /** The database token itself is read-only (studio created it), not just studio's checks. */ tokenReadOnly: boolean; tables: TableDefinition[]; - /** Each table's CREATE statements: the table, then its indexes and triggers. */ ddl: Record; views: ViewDefinition[]; } @@ -32,7 +29,6 @@ export interface StudioSchema { export interface QueryResult { columns: string[]; rows: unknown[][]; - /** The server stopped at its row cap. */ truncated: boolean; durationMs: number; } @@ -109,25 +105,20 @@ export async function fetchRows( }; } -/** The first row where `column` equals `value`, or null; used to follow foreign keys. */ export async function fetchRowWhere( table: string, column: string, value: unknown, ): Promise { - const params = new URLSearchParams({ limit: "1" }); - for (const [key, v] of filterParams( - [{ column, operator: "eq", value: String(value) }], - "and", - )) { - params.append(key, v); - } - const res = await request(`/api/${encodeURIComponent(table)}?${params}`); - const body = (await res.json()) as { data: Row[] }; - return body.data[0] ?? null; + const { rows } = await fetchRows(table, { + page: 1, + limit: 1, + filters: [{ column, operator: "eq", value: String(value) }], + mode: "and", + }); + return rows[0] ?? null; } -/** Run one read-only statement in the SQL console. */ export async function runQuery( sql: string, signal?: AbortSignal, diff --git a/packages/database-studio/client/src/components/CellValue.tsx b/packages/database-studio/client/src/components/CellValue.tsx index 251797d4..18dcaf20 100644 --- a/packages/database-studio/client/src/components/CellValue.tsx +++ b/packages/database-studio/client/src/components/CellValue.tsx @@ -1,9 +1,5 @@ import { base64Size, formatBytes, parseJsonObject } from "@/lib/format"; -/** - * Renders a value by its column type. `inline` is the one-line grid form; - * otherwise long text wraps and JSON is pretty-printed. - */ export function CellValue({ value, type, diff --git a/packages/database-studio/client/src/components/DataGrid.tsx b/packages/database-studio/client/src/components/DataGrid.tsx index 9a53a90a..7851dbbb 100644 --- a/packages/database-studio/client/src/components/DataGrid.tsx +++ b/packages/database-studio/client/src/components/DataGrid.tsx @@ -17,10 +17,8 @@ const MIN_WIDTH = 60; export interface GridColumn { name: string; - /** Declared type; unknown for query results. */ type?: string; primaryKey?: boolean; - /** `table.column` this column references, for the header icon and following the key. */ references?: string; } @@ -29,12 +27,6 @@ interface Cell { col: number; } -/** - * The spreadsheet-style grid behind table browsing and query results. Click a - * cell to select it, move with the arrow keys, copy with Cmd/Ctrl+C, and open - * the row with Enter, a double-click, or the row number. Drag a header's edge - * to resize a column; double-click the edge to reset it. - */ export function DataGrid({ columns, rows, @@ -47,12 +39,11 @@ export function DataGrid({ emptyMessage, }: { columns: GridColumn[]; - /** Positional rows: `rows[i][j]` is column `j`. */ + // Positional so query results can repeat a column name. rows: unknown[][]; offset?: number; sort?: { column: string; order: string }; onSort?: (column: string) => void; - /** The row whose details are open, highlighted. */ activeRow?: number | null; onOpenRow?: (index: number) => void; onFollowForeignKey?: (col: number, value: unknown) => Promise; @@ -64,7 +55,6 @@ export function DataGrid({ const [followErrors, setFollowErrors] = useState>({}); const containerRef = useRef(null); - // A new page or result starts without a selection. // biome-ignore lint/correctness/useExhaustiveDependencies: reset when the rows change useEffect(() => setSelected(null), [rows]); @@ -217,7 +207,6 @@ export function DataGrid({ ); })} - {/* Takes up spare width so data columns size to their content. */}
{col.defaultValue === null || col.defaultValue === undefined ? ( - — + - ) : ( String(col.defaultValue) )} @@ -174,7 +174,7 @@ export function SchemaTab({ {idx.unique ? ( UNIQUE ) : ( - — + - )}