diff --git a/.changeset/studio-read-only.md b/.changeset/studio-read-only.md new file mode 100644 index 00000000..f79d7dee --- /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. It adds a read-only SQL console with autocomplete and history, a database overview with views and triggers, `CREATE` statements on the Schema tab, and a grid with resizable columns and keyboard cell selection. The UI also gets a searchable table picker (⌘K) for its tabs, tabs that keep their own filters and paging, working NOT LIKE / IS NOT NULL / OR filters, row details that follow foreign keys and show referencing rows, a fuller schema view, and CSV/JSON export of the current page. Tables with 64-bit integers or BLOBs no longer fail to load, and composite primary keys are introspected correctly. 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..8f31dcf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -518,6 +518,9 @@ Everything database-related goes through `@bunny.net/database-client`. `db shell ### Other database rules - **Never leak error text through the REST surface.** `createRestHandler()` returns `{ message: "Internal error", code: "INTERNAL_ERROR" }` with a 500 and hands the real error to the optional `onError` hook, because a raw SQLite error would leak table names, column names, and file paths. `db studio` wires `onError` to its own logger, which is why failures print in the launching terminal rather than the browser. +- **Studio is read-only at every layer.** `db studio` asks `resolveCredentials()` for a `read-only` token, and `startStudio()` answers anything but `GET` under `/api` with a 405, because a token from `--token` or `.env` may still be full-access. The SQL console (`runReadOnlyQuery()` in `database-studio/src/query.ts`) allows only read keywords, rejects any statement containing a write keyword (catching `WITH … DELETE`), and allows only PRAGMAs that read. Adding writes means lifting each layer deliberately, not just one. +- **Everything studio runs must pass a read-only token.** libSQL servers classify each statement and refuse anything they call a write when the token is read-only, and their list is stricter than SQLite's: `PRAGMA foreign_key_list` and `PRAGMA user_version` count as writes, and `PRAGMA query_only = ON` is rejected for every token. Use the table-valued form (`SELECT * FROM pragma_foreign_key_list(?)`), which is a SELECT. `introspect.test.ts` fails if introspection issues anything else; local SQLite won't catch this, since it doesn't check scopes. +- **The SQL console shows SQL errors; nothing else does.** Its errors are about the query the person just typed, so `DatabaseError`s without an HTTP status go back to the browser. Transport and unexpected errors stay generic and go to the terminal, as with the REST surface. - **Every CLI connection identifies itself** via `databaseUserAgent(command)` (`bunny-cli/ (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..e1ecb9ed 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": { @@ -189,13 +189,18 @@ "@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-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 +218,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 +236,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 +252,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 +264,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 +275,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 +286,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 +297,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 +308,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 +321,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 +332,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 +343,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:*", @@ -555,6 +560,20 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], + + "@codemirror/commands": ["@codemirror/commands@6.11.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg=="], + + "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], + + "@codemirror/language": ["@codemirror/language@6.13.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/streamparser": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw=="], + + "@codemirror/state": ["@codemirror/state@6.7.6", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w=="], + + "@codemirror/streamparser": ["@codemirror/streamparser@6.0.0", "", {}, "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w=="], + + "@codemirror/view": ["@codemirror/view@6.43.14", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -629,10 +648,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lezer/common": ["@lezer/common@1.5.3", "", {}, "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.5", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w=="], + + "@lezer/lr": ["@lezer/lr@1.4.11", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw=="], + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -673,8 +700,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 +826,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=="], @@ -901,6 +922,8 @@ "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + "crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -1189,6 +1212,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "style-mod": ["style-mod@4.1.4", "", {}, "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], @@ -1229,6 +1254,8 @@ "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -1303,9 +1330,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 +1354,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..a1aa95b2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -373,7 +373,11 @@ bunny db shell --url libsql://01KCHBG8C5KSFGG0VRNFQ7EK7X-my-app.lite.bunnydb.net #### `bunny db studio` -Open a read-only table viewer in your browser. Spins up a local server, generates a short-lived auth token if needed, and opens the studio UI. +Open a read-only database explorer in your browser. Spins up a local server, generates a short-lived read-only token if needed, and opens the studio UI. The local server refuses writes even when `--token` or `.env` supplies a full-access token. + +The home page summarises the database (tables, views, indexes, triggers) with Browse, Structure, and SQL actions per table. Open tables as tabs across the top (⌘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; 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/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..686c7799 100644 --- a/packages/cli/src/commands/db/studio.ts +++ b/packages/cli/src/commands/db/studio.ts @@ -1,15 +1,15 @@ import { defineCommand } from "@/core/define-command.ts"; import { logger } from "@/core/logger.ts"; -import { confirm } from "@/core/ui.ts"; +import { confirm, openBrowser } from "@/core/ui.ts"; import { ARG_DATABASE_ID, databaseUserAgent, TOKEN_TTL_MINUTES, } from "./constants.ts"; -import { resolveCredentials } from "./credentials.ts"; +import { databaseTarget, resolveCredentials } from "./credentials.ts"; const COMMAND = `studio [${ARG_DATABASE_ID}]`; -const DESCRIPTION = "Open a visual database explorer in your browser."; +const DESCRIPTION = "Open a read-only database explorer in your browser."; const ARG_PORT = "port"; const ARG_URL = "url"; @@ -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,14 +108,17 @@ export const dbStudioCommand = defineCommand<{ const { connect } = await import("@bunny.net/database-client"); const { startStudio } = await import("@bunny.net/database-studio"); - const { url, token } = await resolveCredentials({ - url: urlArg, - token: tokenArg, - databaseId: databaseIdArg, - profile, - apiKey, - verbose, - }); + const { url, token, databaseId, tokenGenerated } = await resolveCredentials( + { + url: urlArg, + token: tokenArg, + databaseId: databaseIdArg, + profile, + apiKey, + verbose, + authorization: "read-only", + }, + ); const client = connect({ url, @@ -125,8 +129,11 @@ export const dbStudioCommand = defineCommand<{ logger.log(""); await startStudio({ client, + label: databaseTarget(url, databaseId).label, + // Only a token created here is known to be read-only; one from --token or .env may be full-access. + tokenReadOnly: tokenGenerated, port: port ?? 4488, - open: !noOpen, + open: noOpen ? undefined : openBrowser, dev, logger: { log: (msg: string) => logger.log(msg), 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.test.ts b/packages/database-adapter/src/introspect.test.ts index 0c32ed9d..89c094ed 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,37 @@ describe("introspect", () => { expect(posts.foreignKeys[0]?.referencesTable).toBe("users"); }); + test("reports every column of a composite primary key, in key order", async () => { + const schema = await introspect({ client }); + const postTags = schema.tables.post_tags!; + + expect(postTags.primaryKey).toEqual(["post_id", "tag_id"]); + expect(postTags.columns.every((c) => c.primaryKey)).toBe(true); + }); + + test("only runs statements a read-only libSQL token may run", async () => { + // libSQL servers check each statement's kind against the token's scope. + // These PRAGMAs are the ones they classify as reads; anything else (even + // `PRAGMA foreign_key_list`) needs write access and would break studio, + // which connects with a read-only token. + const READ_PRAGMAS = + /^PRAGMA (table_info|table_xinfo|table_list|index_list|index_info|index_xinfo)\b/i; + const seen: string[] = []; + const recording: AdapterClient = { + prepare: (sql) => { + seen.push(sql.trim()); + return client.prepare(sql); + }, + }; + + await introspect({ client: recording }); + + const notRead = seen.filter( + (sql) => !/^SELECT\b/i.test(sql) && !READ_PRAGMAS.test(sql), + ); + expect(notRead).toEqual([]); + }); + test("uses custom version", async () => { const schema = await introspect({ client, version: "2.0.0" }); expect(schema.version).toBe("2.0.0"); diff --git a/packages/database-adapter/src/introspect.ts b/packages/database-adapter/src/introspect.ts index bcfd63bc..ed7a9e45 100644 --- a/packages/database-adapter/src/introspect.ts +++ b/packages/database-adapter/src/introspect.ts @@ -50,32 +50,43 @@ 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 ( 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, @@ -111,10 +122,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); @@ -152,7 +162,8 @@ const matchesPattern = (name: string, pattern: string): boolean => { return name === pattern; }; -const shouldInclude = ( +/** Whether introspection keeps a table or view named `name`, by the same rules as `exclude`/`include`. */ +export const shouldInclude = ( name: string, exclude: string[], include?: string[], 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..cf8f01ba 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 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 ``` @@ -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..6ae5c340 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,74 @@ 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("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)"); + 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..c1788b0a 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. + */ +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; +}; + 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..20862948 100644 --- a/packages/database-rest/src/index.ts +++ b/packages/database-rest/src/index.ts @@ -2,9 +2,11 @@ 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, + 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..1f796f75 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,84 @@ function parseIsValue(raw: string): boolean | null { return null; } -function parseValue(raw: string): string | number { +/** + * 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 num; + return Number.isFinite(num) && + Number.isSafeInteger(Math.trunc(num)) && + String(num) === raw + ? num + : raw; +} + +/** + * 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; + } } - return raw; + 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); + // `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 }; } export function parseOrder(orderParam: string | null): OrderClause[] { @@ -157,7 +243,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)) { @@ -165,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-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..decdd4a5 100644 --- a/packages/database-studio/client/src/App.tsx +++ b/packages/database-studio/client/src/App.tsx @@ -1,221 +1,184 @@ -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, useRef, useState } from "react"; +import { fetchSchema, type StudioSchema } from "@/api"; +import { LoadingScreen } from "@/components/LoadingScreen"; +import { Overview } from "@/components/Overview"; +import { type ConsoleRequest, SqlConsole } from "@/components/SqlConsole"; +import { TabBar } from "@/components/TabBar"; +import { TableView } from "@/components/TableView"; +import { + SQL_TAB, + setUrlParams, + TABLE_VIEW_PARAMS, + useUrlParam, +} from "@/hooks/use-url-state"; +import type { FilterCondition } from "@/lib/filters"; +import { quoteIdent } from "@/lib/sql"; + +export type OpenTable = (name: string, filters?: FilterCondition[]) => void; + +type ViewState = Record; + +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 [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 [pickerOpen, setPickerOpen] = useState(false); + const [consoleRequest, setConsoleRequest] = useState( + null, + ); + const activeName = useUrlParam("table"); + const isSql = activeName === SQL_TAB; + // Once opened, the console stays mounted so its editor and results survive tab switches. + const [consoleMounted, setConsoleMounted] = useState(isSql); + useEffect(() => { + if (isSql) setConsoleMounted(true); + }, [isSql]); const openParam = useUrlParam("open"); - const activeTab = useUrlParam("tab"); - const openTabs = openParam ? openParam.split(",").filter(Boolean) : []; + // Each tab keeps its filters, sort, and page while another tab is active. + const tabState = useRef(new Map()); 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); + 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 && current !== SQL_TAB) { + tabState.current.set(current, currentViewState()); } - } - 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, + + const tabs = opts.tabs ?? openKey.split(",").filter(Boolean); + if (name && name !== SQL_TAB && !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 openView(name: string) { + setConsoleRequest({ + sql: `SELECT * FROM ${quoteIdent(name)} LIMIT 100;`, + nonce: Date.now(), }); - setMenuOpen(false); + switchTo(SQL_TAB); } 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; + tabState.current.delete(name); + const tabs = openTabs.filter((t) => t !== name); + if (name !== activeName) { + setUrlParams({ open: tabs.length ? tabs.join(",") : null }); + return; } - setUrlParams({ - open: next.length ? next.join(",") : null, - tab: newActive, - filters: null, - page: null, - }); + 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 }); } - function switchTab(name: string) { - setUrlParams({ - tab: name, - filters: null, - page: null, - }); - } + 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(() => { + if (schema) { + document.title = isSql + ? "SQL · Database Studio" + : activeName + ? `${activeName} · Database Studio` + : "Database Studio"; + } + }, [schema, activeName, isSql]); - 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 && ( -
-
- +
+ switchTo(name)} + onSelectView={openView} + onClose={closeTab} + onHome={() => switchTo(null)} + onOpenSql={() => switchTo(SQL_TAB)} + /> +
+ {consoleMounted && ( +
+
- - {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 ? ( - + )} + {isSql ? null : active ? ( + ) : ( -
-
-
-

- Tables -

-

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

-
- {tables.map((table, i) => ( - - ))} -
-
-
-
+ { + switchTo(name); + if (view) setUrlParams({ view }); + }} + onSelectView={openView} + onOpenSql={() => switchTo(SQL_TAB)} + /> )}
diff --git a/packages/database-studio/client/src/api.ts b/packages/database-studio/client/src/api.ts index fa7e184d..464e5f9c 100644 --- a/packages/database-studio/client/src/api.ts +++ b/packages/database-studio/client/src/api.ts @@ -1,261 +1,132 @@ -import type { OpenAPISpec } from "@bunny.net/database-openapi"; +import type { + ColumnDefinition, + TableDefinition, +} from "@bunny.net/database-openapi"; +import { + type FilterCondition, + type FilterMode, + filterParams, +} from "@/lib/filters"; -export interface TableSummary { - name: string; -} +export type { ColumnDefinition, TableDefinition }; -export interface ColumnSchema { +export type Row = Record; + +export interface ViewDefinition { name: string; - type: string; - notnull: boolean; - defaultValue: string | null; - primaryKey: boolean; - nullable: boolean; + sql: string | null; + columns: string[]; } -export interface TableSchema { - columns: ColumnSchema[]; - foreignKeys: { from: string; table: string; to: string }[]; - indexes: { name: string; unique: boolean }[]; +export interface StudioSchema { + label: string | null; + tokenReadOnly: boolean; + tables: TableDefinition[]; + ddl: Record; + views: ViewDefinition[]; } -export interface RowsResponse { +export interface QueryResult { columns: string[]; - rows: Record[]; - pagination: { - page: number; - limit: number; - totalRows: number; - totalPages: number; - }; - responseTime: number; + rows: unknown[][]; + truncated: boolean; + durationMs: 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 ?? {})); - - 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); +export interface RowsQuery { + page: number; + limit: number; + filters: FilterCondition[]; + mode: FilterMode; + sort?: { column: string; order: "asc" | "desc" }; +} - return { - name: colName, - type: mapOpenAPIType(colSchema.type, colSchema.format), - notnull: !colSchema.nullable && required.has(colName), - defaultValue: null, - primaryKey: isPk, - nullable: colSchema.nullable ?? false, - }; - }, - ); +export interface RowsResponse { + rows: Row[]; + totalRows: number; + totalPages: number; + responseTime: number; +} - 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 }); - } +async function request( + url: string, + signal?: AbortSignal, + init?: RequestInit, +): Promise { + const res = await fetch(url, { ...init, signal, credentials: "same-origin" }); + if (res.ok) return res; + + 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}`); + 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}`); } - - 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); - } + for (const [key, value] of filterParams(query.filters, query.mode)) { + params.append(key, value); } - 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); - } - - 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), }; -}; +} + +export async function fetchRowWhere( + table: string, + column: string, + value: unknown, +): Promise { + const { rows } = await fetchRows(table, { + page: 1, + limit: 1, + filters: [{ column, operator: "eq", value: String(value) }], + mode: "and", + }); + return rows[0] ?? null; +} + +export async function runQuery( + sql: string, + signal?: AbortSignal, +): Promise { + const res = await request("/studio-api/query", signal, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql }), + }); + return res.json(); +} 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..18dcaf20 --- /dev/null +++ b/packages/database-studio/client/src/components/CellValue.tsx @@ -0,0 +1,38 @@ +import { base64Size, formatBytes, parseJsonObject } from "@/lib/format"; + +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..7851dbbb --- /dev/null +++ b/packages/database-studio/client/src/components/DataGrid.tsx @@ -0,0 +1,317 @@ +import { + ArrowDown, + ArrowRight, + ArrowUp, + KeyRound, + Link2, + Maximize2, +} from "lucide-react"; +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"]); +const MIN_WIDTH = 60; + +export interface GridColumn { + name: string; + type?: string; + primaryKey?: boolean; + references?: string; +} + +interface Cell { + row: number; + col: number; +} + +export function DataGrid({ + columns, + rows, + offset = 0, + sort, + onSort, + activeRow = null, + onOpenRow, + onFollowForeignKey, + emptyMessage, +}: { + columns: GridColumn[]; + // Positional so query results can repeat a column name. + rows: unknown[][]; + offset?: number; + sort?: { column: string; order: string }; + onSort?: (column: string) => void; + activeRow?: number | null; + onOpenRow?: (index: number) => void; + onFollowForeignKey?: (col: number, value: unknown) => Promise; + emptyMessage: string; +}) { + const [selected, setSelected] = useState(null); + const [widths, setWidths] = useState>({}); + const { copied, copy } = useCopy(900); + const [followErrors, setFollowErrors] = useState>({}); + const containerRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset when the rows change + useEffect(() => setSelected(null), [rows]); + + useEffect(() => { + if (!selected) return; + containerRef.current + ?.querySelector(`[data-cell="${selected.row}:${selected.col}"]`) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [selected]); + + const widthStyle = (name: string): React.CSSProperties | undefined => { + const w = widths[name]; + return w ? { width: w, minWidth: w, maxWidth: w } : undefined; + }; + + 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; + const move = (row: number, col: number) => { + e.preventDefault(); + setSelected({ + row: Math.max(0, Math.min(rows.length - 1, row)), + col: Math.max(0, Math.min(columns.length - 1, col)), + }); + }; + switch (e.key) { + case "ArrowUp": + return move(selected.row - 1, selected.col); + case "ArrowDown": + return move(selected.row + 1, selected.col); + case "ArrowLeft": + return move(selected.row, selected.col - 1); + case "ArrowRight": + return move(selected.row, selected.col + 1); + case "Home": + return move(selected.row, 0); + case "End": + return move(selected.row, columns.length - 1); + case "Enter": + e.preventDefault(); + onOpenRow?.(selected.row); + return; + case "Escape": + setSelected(null); + return; + } + if ((e.metaKey || e.ctrlKey) && e.key === "c") { + // Leave a real text selection to the browser's own copy. + if (window.getSelection()?.toString()) return; + e.preventDefault(); + copyCell(selected); + } + } + + function startResize(e: React.PointerEvent, name: string) { + const th = (e.currentTarget as HTMLElement).closest("th"); + if (!th) return; + const startWidth = th.getBoundingClientRect().width; + startDrag(e, (dx) => + setWidths((w) => ({ + ...w, + [name]: Math.max(MIN_WIDTH, Math.round(startWidth + dx)), + })), + ); + } + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: the key handler is the grid's keyboard model +
+ + + + + ); + })} + + + + {rows.map((row, i) => ( + + + {columns.map((col, j) => { + const value = row[j]; + const key = `${i}:${j}`; + const isSelected = selected?.row === i && selected.col === j; + const hasValue = value !== null && value !== undefined; + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: arrow keys on the focused grid move the selection + + ); + })} + + ))} + +
+ {columns.map((col, j) => { + const sorted = sort?.column === col.name ? sort.order : null; + return ( + + + {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only affordance; columns stay readable without resizing */} +
startResize(e, col.name)} + onDoubleClick={() => + setWidths(({ [col.name]: _, ...rest }) => rest) + } + title="Drag to resize, double-click to reset" + className="absolute -right-1 top-0 z-10 h-full w-2 cursor-col-resize hover:bg-primary/40" + /> +
+
+ + setSelected({ row: i, col: j })} + onDoubleClick={() => onOpenRow?.(i)} + className={cn( + "cursor-default border-b border-r p-0", + col.type && NUMERIC.has(col.type) && "text-right", + )} + > +
+
+ +
+ {col.references && hasValue && onFollowForeignKey && ( + + )} +
+
+
+ {rows.length === 0 && ( +

+ {emptyMessage} +

+ )} +
+ ); +} 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..96e51f54 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,186 @@ 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; +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); - - function addRow() { - const newIndex = count; - rowRefs.current.set(newIndex, { +}: { + columns: ColumnDefinition[]; + applied: FilterCondition[]; + mode: FilterMode; + onApply: (filters: FilterCondition[], mode: FilterMode) => void; +}) { + // Re-mounted via key when the applied filters change, so drafts start from the URL. + const blank = () => + draft({ column: columns[0]?.name ?? "", - operator: "=", - valueRef: null, + operator: defaultOperator(columns[0]), + value: "", }); - onFilterRowCountChange(count + 1); - } + const [drafts, setDrafts] = useState(() => + applied.length > 0 ? applied.map(draft) : [blank()], + ); - 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); - } + 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 clearAll() { - rowRefs.current.clear(); - onFilterRowCountChange(0); - onApply(new Map()); + function update(id: number, patch: Partial) { + setDrafts((ds) => ds.map((d) => (d.id === id ? { ...d, ...patch } : d))); } - function apply() { - onApply(rowRefs.current); + function remove(id: number) { + const next = drafts.filter((d) => d.id !== id); + setDrafts(next); + onApply(strip(next), mode); } 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/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 new file mode 100644 index 00000000..81f0c9ed --- /dev/null +++ b/packages/database-studio/client/src/components/Overview.tsx @@ -0,0 +1,220 @@ +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 ( +
+
{value}
+
{label}
+
+ ); +} + +function Action({ + onClick, + children, +}: { + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +const Th = ({ children }: { children?: React.ReactNode }) => ( + + {children} + +); + +export function Overview({ + schema, + missing, + onSelect, + onSelectView, + onOpenSql, +}: { + schema: StudioSchema; + missing: string | null; + onSelect: (name: string, view?: "schema") => void; + onSelectView: (name: string) => void; + onOpenSql: () => void; +}) { + const { tables, views, ddl } = schema; + const triggers = (table: string) => + (ddl[table] ?? []).filter((s) => /^\s*CREATE\s+TRIGGER/i.test(s)).length; + const totals = { + indexes: tables.reduce((n, t) => n + t.indexes.length, 0), + triggers: tables.reduce((n, t) => n + triggers(t.name), 0), + }; + + return ( +
+
+
+
+

Database

+

+ {missing + ? `There's no table named “${missing}”. Pick one below.` + : "Browse a table, read its structure, or query it in SQL."} +

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

+ This database has no tables yet +

+

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

+
+ ) : ( +
+

+ Tables +

+
+ + + + + + + + + + + + + + {tables.map((t) => ( + + + + + + + + + + + ))} + +
TablePrimary keyColumnsIndexesForeign keysReferenced byTriggers +
+ + + {t.primaryKey.length > 0 ? ( + + + {t.primaryKey.join(", ")} + + ) : ( + "-" + )} + + onSelect(t.name)}>Browse + onSelect(t.name, "schema")}> + Structure + + onSelectView(t.name)}> + SQL + +
+
+
+ )} + + {views.length > 0 && ( +
+

+ Views +

+
+ + + + + + + + + + {views.map((v) => ( + + + + + + ))} + +
ViewColumnsDefinition
+ + + {v.sql?.replace(/\s+/g, " ")} +
+
+
+ )} +
+
+ ); +} + +function Count({ n }: { n: number }) { + return ( + + {n === 0 ? 0 : n} + + ); +} 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..b9f0e374 --- /dev/null +++ b/packages/database-studio/client/src/components/Pager.tsx @@ -0,0 +1,118 @@ +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; + +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/Popover.tsx b/packages/database-studio/client/src/components/Popover.tsx new file mode 100644 index 00000000..174a4d73 --- /dev/null +++ b/packages/database-studio/client/src/components/Popover.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +// Render 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..612a851c --- /dev/null +++ b/packages/database-studio/client/src/components/RowSheet.tsx @@ -0,0 +1,282 @@ +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 { useCopy } from "@/lib/interaction"; +import { incomingReferences } from "@/lib/schema"; +import { cn } from "@/lib/utils"; + +export interface SheetEntry { + table: TableDefinition; + row: Row; +} + +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 ? incomingReferences(tables, entry.table.name) : []; + + 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.referencesColumn]; + 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..7642ba93 100644 --- a/packages/database-studio/client/src/components/SchemaTab.tsx +++ b/packages/database-studio/client/src/components/SchemaTab.tsx @@ -1,130 +1,244 @@ -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 { Check, Copy, KeyRound } from "lucide-react"; +import type { OpenTable } from "@/App"; +import type { TableDefinition } from "@/api"; +import { useCopy } from "@/lib/interaction"; +import { incomingReferences } from "@/lib/schema"; + +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, + ddl, + onOpenTable, }: { - schema: TableSchema | null; - onSelectTable: (name: string) => void; + table: TableDefinition; + tables: TableDefinition[]; + ddl: string[]; + onOpenTable: OpenTable; }) { - if (!schema) return null; + const { copied, copy } = useCopy(); + 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 = incomingReferences(tables, table.name); 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} + + + ))} + +
+ )} + {definition && ( +
+
+

+ Definition +

+ +
+
+            {definition}
+          
+
)}
); } + +function Tag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} 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..c23292af --- /dev/null +++ b/packages/database-studio/client/src/components/SqlConsole.tsx @@ -0,0 +1,299 @@ +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 { downloadRows } from "@/lib/format"; +import { MOD_KEY, startDrag } from "@/lib/interaction"; +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; + +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(); +} + +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; + downloadRows("query", result.columns, result.rows, format); + setExportOpen(false); + } + + function startResize(e: React.PointerEvent) { + const start = editorHeight; + startDrag(e, (_, dy) => + setEditorHeight( + Math.max(80, Math.min(window.innerHeight - 200, start + dy)), + ), + ); + } + + 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 + +
+
+
+ + + {schema.tokenReadOnly + ? "Read-only token · SELECT, WITH, EXPLAIN, PRAGMA, VALUES" + : "Your token · writes blocked by studio · SELECT, WITH, EXPLAIN, PRAGMA, VALUES"} + +
+ +
+ +
+
+ +
+ {running && ( +
+
+
+ )} + {error ? ( +
+ {error} +
+ ) : result ? ( + ({ name }))} + rows={result.rows} + emptyMessage={ + result.columns.length === 0 + ? "The statement ran and returned no columns." + : "No rows." + } + /> + ) : ( +
+

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..5e6ccaa3 --- /dev/null +++ b/packages/database-studio/client/src/components/SqlEditor.tsx @@ -0,0 +1,183 @@ +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)", + }, +]); + +export function SqlEditor({ + value, + onChange, + onRun, + schema, +}: { + value: string; + onChange: (value: string) => void; + onRun: (sql: string) => void; + 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(); + }, []); + + 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 new file mode 100644 index 00000000..0aa052ac --- /dev/null +++ b/packages/database-studio/client/src/components/TabBar.tsx @@ -0,0 +1,166 @@ +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 { MOD_KEY } from "@/lib/interaction"; +import { cn } from "@/lib/utils"; + +export function TabBar({ + schema, + openTabs, + activeTab, + pickerOpen, + onPickerOpenChange, + onSelect, + onSelectView, + onClose, + onHome, + onOpenSql, +}: { + schema: StudioSchema; + openTabs: string[]; + activeTab: string | null; + 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(() => { + 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..b150df3e --- /dev/null +++ b/packages/database-studio/client/src/components/TablePicker.tsx @@ -0,0 +1,131 @@ +import { Eye, Search, Table2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { TableDefinition, ViewDefinition } from "@/api"; +import { cn } from "@/lib/utils"; + +interface Item { + kind: "table" | "view"; + name: string; + columns: number; +} + +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 ? items.filter((i) => i.name.toLowerCase().includes(q)) : items; + }, [tables, views, search]); + + useEffect(() => { + listRef.current + ?.querySelector(`[data-index="${highlight}"]`) + ?.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(); + 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 item = visible[highlight]; + if (item) choose(item); + } + } + + return ( +
+
+ + { + setSearch(e.target.value); + setHighlight(0); + }} + onKeyDown={onKeyDown} + placeholder="Open a table or view…" + 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((item, i) => { + const Icon = item.kind === "table" ? Table2 : Eye; + return ( + + ); + })} + {visible.length === 0 && ( +

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

+ )} +
+
+ ); +} diff --git a/packages/database-studio/client/src/components/TableView.tsx b/packages/database-studio/client/src/components/TableView.tsx index 466e2e9e..9df8da63 100644 --- a/packages/database-studio/client/src/components/TableView.tsx +++ b/packages/database-studio/client/src/components/TableView.tsx @@ -1,409 +1,446 @@ -import type { SortingState, VisibilityState } from "@tanstack/react-table"; -import { - ChevronLeft, - ChevronRight, - Columns3, - Filter, - RefreshCw, -} from "lucide-react"; +import { Columns3, Download, Filter, RefreshCw } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import { - FILTER_OPERATORS, - type FilterCondition, - type FilterMode, - fetchTableRows, - fetchTableSchema, - type RowsResponse, - type TableSchema, -} from "@/api.ts"; -import { ColumnToggleMenu } from "@/components/ColumnToggleMenu"; -import { DataTab } from "@/components/DataTab"; -import { FilterBar, NULLARY_OPERATORS } from "@/components/FilterBar"; +import type { OpenTable } from "@/App"; +import { fetchRowWhere, type RowsQuery, type TableDefinition } from "@/api"; +import { DataGrid, type GridColumn } from "@/components/DataGrid"; +import { FilterBar } from "@/components/FilterBar"; +import { DEFAULT_PAGE_SIZE, PAGE_SIZES, Pager } from "@/components/Pager"; +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 { useTableRows } from "@/hooks/use-table-rows"; import { setUrlParams, useUrlParam } from "@/hooks/use-url-state"; +import { + type FilterCondition, + type FilterMode, + isComplete, + isOperator, +} from "@/lib/filters"; +import { downloadRows } from "@/lib/format"; +import { cn } from "@/lib/utils"; -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); - 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); +export function TableView({ + table, + tables, + ddl, + onOpenTable, +}: { + table: TableDefinition; + tables: TableDefinition[]; + ddl: string[]; + onOpenTable: OpenTable; +}) { + 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 view = viewParam === "schema" ? "schema" : "data"; + 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); - const page = Math.max(1, Number(pageParam) || 1); - const limit = Math.min(100, Math.max(1, Number(limitParam) || 50)); + const limit = PAGE_SIZES.includes(Number(limitParam)) + ? Number(limitParam) + : DEFAULT_PAGE_SIZE; + const page = Math.max(1, Math.floor(Number(pageParam)) || 1); + const sort: RowsQuery["sort"] = + sortParam && columnNames.has(sortParam) + ? { column: sortParam, order: orderParam === "desc" ? "desc" : "asc" } + : undefined; - const columnNames = useMemo(() => { - if (!schema) return null; - return new Set(schema.columns.map((c) => c.name)); - }, [schema]); + const { data, loading, error, refresh } = useTableRows(table.name, { + page, + limit, + filters, + mode, + sort, + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: a new page clears the highlighted row + useEffect(() => setSelectedRow(null), [data]); - const sort = useMemo(() => { - if (!sortParam || !columnNames?.has(sortParam)) { - return undefined; + // 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) }); } - return { - column: sortParam, - order: (orderParam === "desc" ? "desc" : "asc") as "asc" | "desc", - }; - }, [sortParam, orderParam, columnNames]); + }, [data, page]); - 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; + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + const target = e.target as HTMLElement; + if ( + target.closest( + "input, textarea, select, [contenteditable], [role=dialog]", + ) + ) { + return; } - if (typeof value !== "string") continue; - validated.push({ column, operator, value }); + if (e.key === "r" && !e.metaKey && !e.ctrlKey && !e.altKey) refresh(); } - return validated; - }, [filtersParam, columnNames]); - - // Sync filter row count from URL on table change - useEffect(() => { - setFilterRowCount(appliedFilters.length); - if (appliedFilters.length > 0) setFiltersOpen(true); - }, [appliedFilters.length]); - - 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]); - - useEffect(() => { - if (!schema) return; - let cancelled = false; - setLoading(true); - fetchTableRows(tableName, page, limit, appliedFilters, sort, filterMode) - .then((d) => { - if (cancelled) return; - setData(d); - setError(null); - }) - .catch((e) => { - if (cancelled) return; - setError(e instanceof Error ? e.message : String(e)); - }) - .finally(() => { - if (cancelled) return; - setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [page, limit, tableName, filterMode, sort, appliedFilters, schema]); - - 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)); - } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [refresh]); function setPage(p: number) { - setUrlParams({ page: p === 1 ? null : String(p) }); - } - - function setLimit(l: number) { - setUrlParams({ limit: l === 50 ? null : String(l), page: null }); + 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) { + 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: OpenTable = (name, nextFilters) => { + setSheet([]); + if (nextFilters?.length) setFiltersOpen(true); + onOpenTable(name, nextFilters); + }; + + 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 = useMemo( + () => table.columns.filter((c) => !hidden.has(c.name)), + [table, hidden], + ); + 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], + ); + + function exportPage(format: "csv" | "json") { + downloadRows( + `${table.name}-page-${page}`, + visibleColumns.map((c) => c.name), + gridRows, + format, ); + setExportOpen(false); } return ( -
-
-
- - - {tab === "data" && ( - <> +
+
+
+ {(["data", "schema"] as const).map((v) => ( + + ))} +
+ + {view === "data" && ( + <> + + +
+ setColumnsOpen(false)} + className="w-56" + > +
+ {table.columns.map((col) => ( + { + const next = new Set(hidden); + if (next.has(col.name)) next.delete(col.name); + else next.add(col.name); + setHidden(next); + }} + > + + {col.name} + + ))} +
+ {hidden.size > 0 && ( +
+ setHidden(new Set())} + className="text-muted-foreground" + > + Show all columns + +
+ )} +
+
+ +
- {columnsOpen && schema && ( - c.name)} - visibility={columnVisibility} - onChange={setColumnVisibility} - onClose={() => setColumnsOpen(false)} - /> - )} + setExportOpen(false)} + align="right" + className="w-44" + > + exportPage("csv")}> + This page as CSV + + exportPage("json")}> + This page as JSON + +
- - )} -
- -
- {tab === "data" && data && ( - <> +
+ + )} +
- {data.responseTime != null && ( - - {data.responseTime}ms - - )} - - - {data.pagination.totalRows.toLocaleString()} rows - - - - - - {data.pagination.page} / {data.pagination.totalPages} - - - - - + {view === "schema" ? ( + + ) : ( + <> + {filtersOpen && ( + )} -
-
- {error && data && ( -
- Failed to refresh: {error} -
- )} +
+ {loading && data && ( +
+
+
+ )} + {error ? ( +
+

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

+

+ {error} +

+
+ + {filters.length > 0 && ( + + )} +
+
+ ) : data ? ( + { + const row = data.rows[index]; + if (!row) return; + setSelectedRow(index); + setSheet([{ table, row }]); + }} + 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." + : "This table is empty." + } + /> + ) : ( +
+ Loading rows… +
+ )} +
- {filtersOpen && tab === "data" && schema && ( - + + setUrlParams({ + limit: n === DEFAULT_PAGE_SIZE ? null : String(n), + page: null, + }) + } + /> + )} - {tab === "data" ? ( - - ) : ( - - )} + setSheet((s) => s.slice(0, -1))} + onClose={() => { + setSheet([]); + setSelectedRow(null); + }} + onFollowForeignKey={followForeignKey} + onOpenTable={openTable} + />
); } 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/button.tsx b/packages/database-studio/client/src/components/ui/button.tsx index 6db73e79..a9bec27d 100644 --- a/packages/database-studio/client/src/components/ui/button.tsx +++ b/packages/database-studio/client/src/components/ui/button.tsx @@ -53,4 +53,4 @@ function Button({ ); } -export { Button, buttonVariants }; +export { Button }; 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/select.tsx b/packages/database-studio/client/src/components/ui/select.tsx index 4cc30443..b9a3bad3 100644 --- a/packages/database-studio/client/src/components/ui/select.tsx +++ b/packages/database-studio/client/src/components/ui/select.tsx @@ -6,8 +6,6 @@ import { cn } from "@/lib/utils"; const Select = SelectPrimitive.Root; -const SelectGroup = SelectPrimitive.Group; - const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< @@ -97,18 +95,6 @@ const SelectContent = React.forwardRef< )); SelectContent.displayName = SelectPrimitive.Content.displayName; -const SelectLabel = React.forwardRef< - React.ComponentRef, - 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/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 ( -
[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-table-rows.ts b/packages/database-studio/client/src/hooks/use-table-rows.ts new file mode 100644 index 00000000..c3471afd --- /dev/null +++ b/packages/database-studio/client/src/hooks/use-table-rows.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { fetchRows, type RowsQuery, type RowsResponse } from "@/api"; + +// 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); + + // Compared by value so 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/hooks/use-url-state.ts b/packages/database-studio/client/src/hooks/use-url-state.ts index 93d42423..0fc74f1a 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; @@ -9,14 +9,16 @@ 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. */ -export function setUrlParams(updates: Record) { +// `push` is for navigation only, so Back skips individual filter and paging edits. +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 +28,24 @@ 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); - // Notify subscribers + if (push) { + window.history.pushState(null, "", url); + } else { + window.history.replaceState(null, "", url); + } window.dispatchEvent(new PopStateEvent("popstate")); } -/** Hook that returns a setter for URL params. */ -export function useSetUrlParams() { - return useCallback(setUrlParams, []); -} +export const TABLE_VIEW_PARAMS = { + view: null, + page: null, + sort: null, + order: null, + filters: null, + mode: null, +} as const; + +// 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.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 new file mode 100644 index 00000000..b7cf0aed --- /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; + nullary?: boolean; + encode: (value: string) => string; +} + +// `%` 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}` }, + 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]; +} + +export function isComplete(f: FilterCondition): boolean { + return isNullary(f.operator) || f.value !== ""; +} + +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, "\\$&")}"`; +} + +const RESERVED_PARAMS = new Set(["select", "order", "limit", "offset", "or"]); + +const groupPart = (f: FilterCondition) => + `${f.column}.${quoteForGroup(OPERATORS[f.operator].encode(f.value))}`; + +export function filterParams( + filters: FilterCondition[], + mode: FilterMode, +): [string, string][] { + const ready = filters.filter(isComplete); + if (mode === "or" && ready.length > 1) { + return [["or", `(${ready.map(groupPart).join(",")})`]]; + } + return ready.map((f): [string, string] => + // 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 new file mode 100644 index 00000000..ff1ec55c --- /dev/null +++ b/packages/database-studio/client/src/lib/format.ts @@ -0,0 +1,72 @@ +export function toText(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +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. +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; +} + +// 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(",")); + } + return `${lines.join("\r\n")}\r\n`; +} + +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); +} + +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 new file mode 100644 index 00000000..575d2561 --- /dev/null +++ b/packages/database-studio/client/src/lib/interaction.ts @@ -0,0 +1,36 @@ +import { useCallback, useState } from "react"; + +export const MOD_KEY = /Mac|iPhone|iPad/.test(navigator.platform) + ? "⌘" + : "Ctrl "; + +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 }; +} + +// 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..4e23e6a7 --- /dev/null +++ b/packages/database-studio/client/src/lib/schema.ts @@ -0,0 +1,13 @@ +import type { TableDefinition } from "@/api"; + +export function incomingReferences(tables: TableDefinition[], table: string) { + 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/client/src/lib/sql.test.ts b/packages/database-studio/client/src/lib/sql.test.ts new file mode 100644 index 00000000..4e775d4d --- /dev/null +++ b/packages/database-studio/client/src/lib/sql.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { quoteIdent, statementAt } from "./sql.ts"; + +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("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"); +}); + +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 new file mode 100644 index 00000000..db418283 --- /dev/null +++ b/packages/database-studio/client/src/lib/sql.ts @@ -0,0 +1,49 @@ +// 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; + 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(); + if (!statement && start > 0) return statementAt(doc, start - 1); + return statement; +} + +// 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 537e97a0..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"); @@ -16,13 +14,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..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: { @@ -22,6 +24,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..4036c92c 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:*", @@ -19,13 +20,18 @@ "@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-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/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 new file mode 100644 index 00000000..efb21b64 --- /dev/null +++ b/packages/database-studio/src/catalog.ts @@ -0,0 +1,71 @@ +import { + DEFAULT_EXCLUDE_PATTERNS, + shouldInclude, +} 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_") || !shouldInclude(name, DEFAULT_EXCLUDE_PATTERNS); + +/** A view's columns, or none when it can't resolve (a view over a dropped table). */ +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 []; + } +} + +/** The CREATE statements (including triggers) and views that introspection leaves out. */ +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; + views.push({ name, sql, columns: await viewColumns(client, name) }); + } else if (tableNames.has(table) && sql) { + ddl[table] ??= []; + ddl[table].push(sql); + } + } + + return { ddl, views }; +} 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/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/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 new file mode 100644 index 00000000..488666d8 --- /dev/null +++ b/packages/database-studio/src/query.test.ts @@ -0,0 +1,165 @@ +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, + rejectionReason, + runReadOnlyQuery, +} from "./query.ts"; + +let sent: string[]; +let reply: { columns: string[]; rows: SqlValue[][] }; + +const client: QueryClient = { + prepare: (sql: string) => + ({ + runRaw: async () => { + sent.push(sql); + return { ...reply, rowsAffected: 0, lastInsertRowid: null }; + }, + }) as never, +}; + +beforeEach(() => { + sent = []; + 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", + ); + + expect(result).toMatchObject({ ...reply, truncated: false }); + }); + + test("sends only the statement itself, nothing libSQL would reject", async () => { + await runReadOnlyQuery(client, "SELECT 1"); + expect(sent).toEqual(["SELECT 1"]); + }); + + test("runs read-only PRAGMAs in their SELECT form, which a read-only token allows", async () => { + await runReadOnlyQuery(client, "PRAGMA table_info('users')"); + expect(sent).toEqual(["SELECT * FROM pragma_table_info('users')"]); + + await runReadOnlyQuery(client, "PRAGMA user_version;"); + expect(sent[1]).toBe("SELECT * FROM pragma_user_version()"); + }); + + 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)", + "WITH x AS (SELECT 1) REPLACE INTO users (id, name) VALUES (1, 'x')", + ]) { + await expect(runReadOnlyQuery(client, sql)).rejects.toBeInstanceOf( + QueryRejected, + ); + } + expect(sent).toEqual([]); + }); + + test("caps the rows it returns and says so", async () => { + 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); + }); +}); + +describe("pragmaAsSelect", () => { + test("passes the argument as a plain string and keeps the schema", () => { + expect(pragmaAsSelect('PRAGMA table_info("my""t")')).toBe( + `SELECT * FROM pragma_table_info('my"t')`, + ); + 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')", + ); + }); +}); + +describe("rejectionReason", () => { + test("ignores write words inside strings, identifiers, comments, and replace()", () => { + for (const sql of [ + "SELECT 'please delete me' AS note", + "SELECT [drop] FROM t", + "SELECT 1 /* insert later */", + "SELECT replace(name, 'a', 'b') FROM users", + "SELECT begin, release, analyze, col2delete FROM events", + ]) { + expect(rejectionReason(sql)).toBeNull(); + } + }); + + test("allows PRAGMAs that only read", () => { + for (const sql of [ + "PRAGMA table_info(users)", + "PRAGMA main.index_list('users')", + "PRAGMA user_version", + ]) { + expect(rejectionReason(sql)).toBeNull(); + } + }); + + 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", + "PRAGMA user_version(5)", + "PRAGMA query_only = ON", + "PRAGMA table_info = users", + ]) { + expect(rejectionReason(sql)).not.toBeNull(); + } + }); +}); + +describe("queryErrorMessage", () => { + 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", () => { + 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("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 new file mode 100644 index 00000000..7863685f --- /dev/null +++ b/packages/database-studio/src/query.ts @@ -0,0 +1,202 @@ +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[][]; + truncated: boolean; + durationMs: number; +} + +export const MAX_ROWS = 1000; + +const READ_KEYWORDS = new Set([ + "SELECT", + "WITH", + "EXPLAIN", + "PRAGMA", + "VALUES", +]); + +// 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", + "DELETE", + "REPLACE", + "CREATE", + "DROP", + "ALTER", +]); + +/** Read-only PRAGMAs that may take a table or index name. */ +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 read a setting only when they stand alone; an argument or `=` sets it. */ +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, strings, and quoted identifiers blanked out. */ +function codeOnly(sql: string): string { + return sql.replace( + /--[^\n]*|\/\*[\s\S]*?\*\/|'(?:[^']|'')*'|"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]/g, + " ", + ); +} + +function firstKeyword(sql: string): string | null { + 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.`; + } + + 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_][A-Za-z0-9_$]*/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; +} + +/** 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, + ); + if (!match) return null; + const [, schema, name, rawArg = ""] = match; + const arg = unquote(rawArg.trim()); + 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; +} + +/** 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, +): Promise { + const statement = sql.trim().replace(/;\s*$/, ""); + if (!statement) throw new QueryRejected("Enter a query to run."); + + 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.prepare(runnable).runRaw(); + const durationMs = Math.round(performance.now() - start); + + return { + 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; + +/** 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; + + 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 8bfdbf9b..878d1620 100644 --- a/packages/database-studio/src/server.ts +++ b/packages/database-studio/src/server.ts @@ -2,13 +2,24 @@ 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"; export interface StudioOptions { client: Database; + /** Shown in the studio header so it's clear which database is open. */ + label?: string; + /** True when the CLI created the token as read-only; otherwise only studio's own statement checks apply. */ + 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; @@ -16,26 +27,31 @@ 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"; +const jsonResponse = ( + body: unknown, + status = 200, + headers?: Record, +): Response => + new Response(JSON.stringify(body, encodeValue), { + status, + 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 ( @@ -45,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, @@ -72,8 +78,10 @@ const handleAuth = async ( export async function startStudio(options: StudioOptions): Promise { const { client, + label, + tokenReadOnly = false, port = 4488, - open = true, + open, dev = false, logger = console, } = options; @@ -82,21 +90,55 @@ 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 }, ); + 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 = 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 { @@ -104,70 +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); - // 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); - } - 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" }, + // 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", }); } + 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 .`, ); @@ -175,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; @@ -185,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}`); @@ -197,26 +204,21 @@ export async function startStudio(options: StudioOptions): Promise { } if (open) { - const proc = Bun.spawn( - process.platform === "darwin" - ? ["open", browserUrl] - : ["xdg-open", browserUrl], - { stdout: "ignore", stderr: "ignore" }, - ); - await proc.exited; + // 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 await new Promise((resolve) => { - process.on("SIGINT", () => { + const stop = () => { viteProc?.kill(); server.stop(); resolve(); - }); - process.on("SIGTERM", () => { - viteProc?.kill(); - server.stop(); - resolve(); - }); + }; + process.on("SIGINT", stop); + process.on("SIGTERM", stop); }); }