Compare commits

...

64 commits

Author SHA1 Message Date
TheGuildBot
b35f9fbc1f
Upcoming Release Changes (#7995)
Some checks failed
ci / package (push) Has been cancelled
ci / build (push) Has been cancelled
ci / publish_docker_cli (push) Has been cancelled
ci / trigger staging deployment (push) Has been cancelled
ci / code-style (push) Has been cancelled
ci / typescript (push) Has been cancelled
ci / graphql-schema (push) Has been cancelled
2026-04-17 14:33:30 +02:00
Michael Skorokhodov
863f920b86
feat: create initial collection prompt + edit name (#7989) 2026-04-17 13:00:28 +02:00
TheGuildBot
64e7fed1d4
Upcoming Release Changes (#7994) 2026-04-17 09:16:47 +02:00
Jonathan Brennan
a71b45bf76
fix Sentry errors related to schema-editor dynamic import (#7990) 2026-04-17 08:36:51 +02:00
Laurin
730771fb50
fix: address vulnerabilities 2026-04-17 (#7993)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-17 08:34:37 +02:00
Michael Skorokhodov
5a85fb9dd6
Feat/lab settings (#7943)
Co-authored-by: Laurin <laurinquast@googlemail.com>
2026-04-16 12:58:10 +02:00
Laurin
d7e7025624
fix: vulnerabilities 2026-04-16 (#7988) 2026-04-16 11:04:18 +02:00
Laurin
fe06ddec5a
fix: a quick changelog fix (#7987) 2026-04-16 09:09:40 +02:00
Jonathan Brennan
a237b4e782
fix wonky SubgraphChip and associated tooltip styling (#7985) 2026-04-15 17:59:09 -05:00
Jonathan Brennan
5706e2de0e
restore GraphQL syntax highlighting across Monaco editors (#7981) 2026-04-15 12:01:08 -05:00
jdolle
9c6989cd92
Support type extensions with unreachable types policy rule (#7978) 2026-04-15 08:12:48 -07:00
Laurin
c46b2f2219
chore: bump axios (#7980) 2026-04-15 14:45:25 +02:00
Michael Skorokhodov
4a8bd4fd1b
CONSOLE-1958: Operation picker (when multiple exists in document) (#7963) 2026-04-15 13:39:54 +02:00
Dotan Simha
e3d9750cc9
chore: remove apollo-router-hive-fork from this repo (#7979) 2026-04-15 11:50:18 +03:00
Jonathan Brennan
7d4ef94432
fix: ensure vars as passed through turbo (#7977) 2026-04-14 13:18:11 -05:00
Michael Skorokhodov
fab4b03ace
Feat/lab query plan (#7892)
Co-authored-by: Laurin <laurinquast@googlemail.com>
2026-04-14 14:47:09 +02:00
Laurin
ed9ab34c70
fix: bump follow-redirects (#7976) 2026-04-14 14:37:30 +02:00
Jonathan Brennan
f7a74ce419
fix sentry sourcemaps: point the inject and upload commands at dist/client instead of dist (#7972) 2026-04-14 07:12:57 -05:00
Laurin
2feec5d5db
chore: bump basic-ftp (#7974) 2026-04-14 13:57:42 +02:00
Adam Benhassen
0162def432
fix(app-deployments): auto-inject MCP directive definitions during operation validation (#7970) 2026-04-13 17:08:08 +00:00
TheGuildBot
c6905421e7
Update apollo-router to 2.13.1 (#7944)
Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com>
2026-04-12 11:41:19 +03:00
TheGuildBot
01184317c7
Update apollo-router to 2.13.0, update rust toolchain 1.94.1 (#7939)
Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com>
Co-authored-by: Dotan Simha <dotansimha@gmail.com>
2026-04-12 10:43:32 +03:00
Laurin
9708f71aa3
fix: schema contract composition applying @inaccessible on the federation types ContextArgument and FieldValue on the supergraph SDL (#7967) 2026-04-10 12:44:41 +02:00
Laurin
40fd27d9c0
chore: update vulnerabilities 2026-04-09 (#7961)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 10:27:33 +02:00
Jonathan Brennan
77d6063512
Console 2012 client side source maps have never been uploaded to sentry (#7952) 2026-04-08 14:16:23 -05:00
Laurin
1333fbcafa
fix: fiter null values from result (#7951) 2026-04-08 08:16:51 -05:00
Laurin
61394f30b0
chore: bump ladle (#7954) 2026-04-08 09:38:37 +02:00
Laurin
0d81a7a01b
chore: bump vite and vitest peer dependency (#7953) 2026-04-08 08:48:48 +02:00
TheGuildBot
2879316c05
Update apollo-router to 2.12.1 (#7903)
Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com>
2026-04-07 14:56:33 +03:00
Dotan Simha
dae36931f9
fix(deployment): upgrade contour to latest version (1.33) and update chart url (#7947) 2026-04-07 14:50:46 +03:00
Jonathan Brennan
ca69b1c59f
update node to 24.14.1 and @types/node to 24.12.2 to address Aikido i… (#7946)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
2026-04-07 11:26:02 +02:00
Laurin
9ce8fda7f8
bump vulnerable dependencies (#7950) 2026-04-07 11:01:41 +02:00
Laurin
84043d9b3a
feat: slonik major upgrade (#7897) 2026-04-02 12:43:37 +02:00
TheGuildBot
615fb095d8
Upcoming Release Changes (#7933) 2026-04-02 12:14:37 +02:00
Laurin
007dc4b8df
chore: bump lodash (#7942) 2026-04-02 11:07:13 +02:00
Dotan Simha
f042e51bc3
fix(deployment): configure loadBalancerPolicy correctly in envoy (#7938) 2026-04-01 11:41:19 +03:00
jdolle
742f50c52e
fix: lint policy block title color; add policy link to lines and rule id tooltip (#7940) 2026-04-01 09:23:06 +02:00
Laurin
73ba28d914
chore: remove envoy auth rate limit (#7937) 2026-03-31 09:25:14 +02:00
jdolle
96bd390c7f
fix: do not cache edge types in graphql eslint (#7936) 2026-03-31 09:03:58 +02:00
Dotan Simha
34528114f3
fix(deployment): update envoy limit for staging 2026-03-30 17:46:03 +03:00
Dotan Simha
fd0b8251c5
fix(deployment): adjust cpu limit/requests config for pods (#7931) 2026-03-30 17:02:42 +03:00
Michael Skorokhodov
574a5d823e
enhancement: lab to fetch schema if no introspection provided in inte… (#7888) 2026-03-30 16:02:23 +02:00
Dotan Simha
d3e0ef500f
fix(deployment): enable metrics scraping for envoy and contour (#7932) 2026-03-30 15:50:49 +02:00
Laurin
14858198e1
chore: vulnerable dependencies (#7930) 2026-03-30 13:20:06 +02:00
Laurin
bd695d0f64
chore: dependency vulnerabilities 2026-03-30 (#7928) 2026-03-30 12:13:12 +02:00
Dotan Simha
aa51ecd0de
fix(deployment): use replicas=1 for otel-collector and allow it to scale when needed (#7929) 2026-03-30 13:09:10 +03:00
Jonathan Brennan
72cf5b8659
upload sentry sourcemaps dir for web/app (#7927) 2026-03-30 12:02:31 +02:00
Laurin
02f3ace50b
chore: bump brace-expansion (#7926) 2026-03-27 15:04:36 +01:00
Laurin
12c990c2d8
fix: change last used date encoding (#7925) 2026-03-27 14:34:14 +01:00
Laurin
883e0bcb02
chore: always run the maximum amount of usage services (#7924) 2026-03-27 13:17:09 +01:00
Copilot
aac23596ec
refactor: shared internal postgres package around slonik (#7887)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: n1ru4l <14338007+n1ru4l@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
2026-03-27 10:20:05 +01:00
Laurin
e83bc29e2a
chore: vulnerabilities 2026-03-27 (#7923) 2026-03-27 10:02:49 +01:00
jdolle
7834a4d52a
Improve delete token dialog box text (#7917) 2026-03-26 19:23:54 +00:00
TheGuildBot
63805c8136
Upcoming Release Changes (#7906) 2026-03-26 10:53:01 +01:00
Laurin
dc34f101e7
chore: vulnerabilities 2026 03 26 part 2 (#7915) 2026-03-26 10:51:28 +01:00
jdolle
576c7558ba
Fix local eslint out of memory issue (#7913) 2026-03-26 10:15:09 +01:00
Laurin
27d1d04e52
chore: js vulnerabilities 2026-03-26 (#7912) 2026-03-26 00:47:54 +01:00
jdolle
484054be27
Add project access token expiration in the UI (#7909) 2026-03-26 00:15:58 +01:00
Jonathan Brennan
4acd1c0446
Console 1994 migrate laboratory from localstorage to indexeddb (#7904) 2026-03-25 16:29:39 +01:00
Laurin
70b3e19fe2
fix: pagination of access tokens (#7908) 2026-03-25 14:02:44 +01:00
Laurin
982d042b3b
chore: revert eslint upgrade (#7907) 2026-03-25 09:56:26 +01:00
jdolle
e5711a52a8
feat: access token expiration (#7893) 2026-03-25 08:34:04 +01:00
Matt Van Horn
5bb70a63ac
fix(dashboard): show skeleton loader while access tokens load (#7895)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jonathan Brennan <jonathanawesome@users.noreply.github.com>
2026-03-25 07:33:49 +00:00
Dotan Simha
ebe8df69fb
fix(billing): only allow PRO plan to self-update their plan limits (#7867) 2026-03-25 07:56:27 +01:00
364 changed files with 12074 additions and 25797 deletions

View file

@ -1,3 +0,0 @@
# The following are aliases you can use with "cargo command_name"
[alias]
"clippy:fix" = "clippy --all --fix --allow-dirty --allow-staged"

View file

@ -65,14 +65,12 @@ module.exports = {
parser: '@graphql-eslint/eslint-plugin',
plugins: ['@graphql-eslint'],
parserOptions: {
graphQLConfig: {
schema: SCHEMA_PATH,
operations: OPERATIONS_PATHS,
documents: OPERATIONS_PATHS,
},
schema: SCHEMA_PATH,
operations: OPERATIONS_PATHS,
},
rules: {
'@graphql-eslint/require-selections': 'error',
'@graphql-eslint/no-deprecated': 'error',
'@graphql-eslint/require-id-when-available': 'error',
},
},
{
@ -85,6 +83,7 @@ module.exports = {
plugins: ['@graphql-eslint'],
rules: {
'@graphql-eslint/no-deprecated': 'error',
'@graphql-eslint/require-id-when-available': 'error',
},
},
{

View file

@ -1,169 +0,0 @@
name: apollo-router-release
on:
# For PRs, this pipeline will use the commit ID as Docker image tag and R2 artifact prefix.
pull_request:
branches:
- main
paths:
- 'packages/libraries/router/**'
- 'packages/libraries/sdk-rs/**'
- 'docker/router.dockerfile'
- 'scripts/compress/**'
- 'configs/cargo/Cargo.lock'
- 'Cargo.lock'
- 'Cargo.toml'
# For `main` changes, this pipeline will look for changes in Rust crates or plugin versioning, and
# publish them only if changes are found and image does not exists in GH Packages.
push:
paths:
- 'packages/libraries/router/**'
- 'packages/libraries/sdk-rs/**'
- 'docker/router.dockerfile'
- 'scripts/compress/**'
- 'configs/cargo/Cargo.lock'
- 'Cargo.lock'
- 'Cargo.toml'
branches:
- main
jobs:
# This script is doing the following:
# 1. Get the version of the apollo-router and the plugin from the Cargo.toml and package.json files
# 2. Check if there are changes in the Cargo.toml and package.json files in the current commit
# 3. If there are changes, check if the image tag exists in the GitHub Container Registry
find-changes:
runs-on: ubuntu-22.04
if: ${{ !github.event.pull_request.head.repo.fork }}
outputs:
should_release: ${{ steps.find_changes.outputs.should_release }}
release_version: ${{ steps.find_changes.outputs.release_version }}
release_latest: ${{ steps.find_changes.outputs.release_latest }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 2
- name: find changes in versions
id: find_changes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then
echo "Running in a PR, using commit ID as tag"
echo "should_release=true" >> $GITHUB_OUTPUT
echo "release_latest=false" >> $GITHUB_OUTPUT
echo "release_version=$GITHUB_SHA" >> $GITHUB_OUTPUT
exit 0
fi
echo "Running on push event, looking for changes in Rust crates or plugin versioning"
image_name="apollo-router"
github_org="graphql-hive"
router_version=$(cargo tree -i apollo-router --quiet | head -n 1 | awk -F" v" '{print $2}')
plugin_version=$(jq -r '.version' packages/libraries/router/package.json)
has_changes=$(git diff HEAD~ HEAD --name-only -- 'packages/libraries/router/Cargo.toml' 'packages/libraries/router/package.json' 'packages/libraries/sdk-rs/Cargo.toml' 'packages/libraries/sdk-rs/package.json' 'Cargo.lock' 'configs/cargo/Cargo.lock')
if [ "$has_changes" ]; then
image_tag_version="router${router_version}-plugin${plugin_version}"
response=$(curl -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-s \
https://api.github.com/orgs/${github_org}/packages/container/${image_name}/versions)
tag_exists=$(echo "$response" | jq -r ".[] | .metadata.container.tags[] | select(. | contains(\"${image_tag_version}\"))")
if [ ! "$tag_exists" ]; then
echo "Found changes in version $version_to_publish"
echo "release_version=$image_tag_version" >> $GITHUB_OUTPUT
echo "should_release=true" >> $GITHUB_OUTPUT
echo "release_latest=true" >> $GITHUB_OUTPUT
else
echo "No changes found in version $image_tag_version"
fi
fi
# Builds Rust crates, and creates Docker images
dockerize:
uses: ./.github/workflows/build-and-dockerize.yaml
name: image-build
needs:
- find-changes
if: ${{ needs.find-changes.outputs.should_release == 'true' }}
with:
imageTag: ${{ needs.find-changes.outputs.release_version }}
publishLatest: ${{ needs.find-changes.outputs.release_latest == 'true' }}
targets: apollo-router-hive-build
build: false
publishPrComment: true
secrets: inherit
# Test the Docker image, if it was published
test-image:
name: test apollo-router docker image
needs:
- dockerize
- find-changes
runs-on: ubuntu-22.04
env:
HIVE_TOKEN: ${{ secrets.HIVE_TOKEN }}
steps:
- name: Run Docker image
run: |
# Create router.yaml
cat << EOF > router.yaml
supergraph:
listen: 0.0.0.0:4000
health_check:
listen: 0.0.0.0:8088
enabled: true
path: /health
plugins:
hive.usage:
enabled: false
EOF
# Download supergraph
curl -sSL https://supergraph.demo.starstuff.dev/ > ./supergraph.graphql
# Run Docker image
docker run -p 4000:4000 -p 8088:8088 --name apollo_router_test -d \
--env HIVE_TOKEN="fake" \
--mount "type=bind,source=/$(pwd)/router.yaml,target=/dist/config/router.yaml" \
--mount "type=bind,source=/$(pwd)/supergraph.graphql,target=/dist/config/supergraph.graphql" \
ghcr.io/graphql-hive/apollo-router:${{ needs.find-changes.outputs.release_version }} \
--log debug \
--supergraph /dist/config/supergraph.graphql \
--config /dist/config/router.yaml
# Wait for the container to be ready
echo "Waiting for the container to be ready..."
sleep 20
HTTP_RESPONSE=$(curl --retry 5 --retry-delay 5 --max-time 30 --write-out "%{http_code}" --silent --output /dev/null "http://127.0.0.1:8088/health")
# Check if the HTTP response code is 200 (OK)
if [ $HTTP_RESPONSE -eq 200 ]; then
echo "Health check successful."
docker stop apollo_router_test
docker rm apollo_router_test
exit 0
else
echo "Health check failed with HTTP status code $HTTP_RESPONSE."
docker stop apollo_router_test
docker rm apollo_router_test
exit 1
fi
# Build and publish Rust crates and binaries
binary:
uses: ./.github/workflows/publish-rust.yaml
secrets: inherit
needs:
- find-changes
if: ${{ needs.find-changes.outputs.should_release == 'true' }}
with:
publish: true
latest: ${{ needs.find-changes.outputs.release_latest == 'true' }}
version: ${{ needs.find-changes.outputs.release_version }}

View file

@ -1,57 +0,0 @@
name: Apollo Router Updater
on:
schedule:
# Every 2 hours
- cron: '0 */2 * * *'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch: {}
jobs:
update:
runs-on: ubuntu-22.04
permissions:
issues: write
pull-requests: write
contents: write
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 1
token: ${{ secrets.BOT_GITHUB_TOKEN }}
- name: setup environment
uses: ./.github/actions/setup
with:
codegen: false
actor: apollo-router-updater
- name: Install Rust
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
with:
toolchain: '1.91.1'
default: true
override: true
- name: Check for updates
id: check
run: |
pnpm tsx ./scripts/apollo-router-action.ts
- name: Run updates
if: steps.check.outputs.update == 'true'
run: cargo update -p apollo-router --precise ${{ steps.check.outputs.version }}
- name: Create Pull Request
if: steps.check.outputs.update == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
with:
token: ${{ secrets.BOT_GITHUB_TOKEN }}
commit-message: Update apollo-router to version ${{ steps.check.outputs.version }}
branch: apollo-router-update-${{ steps.check.outputs.version }}
delete-branch: true
title: ${{ steps.check.outputs.title }}
body: |
Automatic update of apollo-router to version ${{ steps.check.outputs.version }}.
assignees: kamilkisiela,dotansimha
reviewers: kamilkisiela,dotansimha

View file

@ -45,6 +45,9 @@ jobs:
suffix: '-arm64'
runs-on: ${{ matrix.builder }}
name: dockerize (${{ matrix.platform }})
env:
SENTRY_ORG: the-guild-z4
SENTRY_PROJECT: graphql-hive
permissions:
contents: read
packages: write
@ -66,6 +69,8 @@ jobs:
run: pnpm build
env:
NODE_OPTIONS: '--max-old-space-size=14336' # GitHub Actions gives us 16GB, it's ok to use 80%
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RELEASE: ${{ inputs.imageTag }}
- name: test ESM & CJS exports integrity
if: ${{ inputs.build }}
@ -191,8 +196,6 @@ jobs:
if: ${{ inputs.publishSourceMaps }}
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_ORG: the-guild-z4
SENTRY_PROJECT: graphql-hive
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE: ${{ inputs.imageTag }}
run: pnpm upload-sourcemaps

View file

@ -93,7 +93,7 @@ jobs:
restoreDeletedChangesets: true
buildScript: build:libraries
packageManager: pnpm
nodeVersion: '24.13'
nodeVersion: '24.14'
secrets:
githubToken: ${{ secrets.GITHUB_TOKEN }}
npmToken: ${{ secrets.NPM_TOKEN }}

View file

@ -1,169 +0,0 @@
on:
workflow_call:
inputs:
publish:
default: false
type: boolean
required: true
latest:
default: false
type: boolean
required: true
version:
default: ${{ github.sha }}
type: string
required: true
jobs:
detect-changes:
runs-on: ubuntu-22.04
outputs:
rust_changed: ${{ steps.rust_changed.outputs.rust_changed }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 2
- name: Look for changes
id: rust_changed
run: |
lines=$( git diff HEAD~ HEAD --name-only -- 'packages/libraries/router' 'packages/libraries/sdk-rs' 'Cargo.toml' 'configs/cargo/Cargo.lock' | wc -l )
if [ $lines -gt 0 ]; then
echo 'rust_changed=true' >> $GITHUB_OUTPUT
fi
test-rust:
needs: detect-changes
if: needs.detect-changes.outputs.rust_changed == 'true'
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 2
- name: setup environment
uses: ./.github/actions/setup
with:
actor: test-rust
codegen: false
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Rust
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
with:
toolchain: '1.91.1'
default: true
override: true
- name: Cache Rust
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
- name: Run tests
run: cargo test
publish-rust:
needs: [detect-changes, test-rust]
if: needs.detect-changes.outputs.rust_changed == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 2
- name: setup environment
uses: ./.github/actions/setup
with:
actor: publish-rust
codegen: false
- name: Prepare MacOS
if: ${{ matrix.os == 'macos-latest' }}
run: |
echo "RUST_TARGET=x86_64-apple-darwin" >> $GITHUB_ENV
echo "RUST_TARGET_FILE=router" >> $GITHUB_ENV
echo "RUST_TARGET_OS=macos" >> $GITHUB_ENV
- name: Prepare Linux
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
echo "RUST_TARGET=x86_64-unknown-linux-gnu" >> $GITHUB_ENV
echo "RUST_TARGET_FILE=router" >> $GITHUB_ENV
echo "RUST_TARGET_OS=linux" >> $GITHUB_ENV
- name: Prepare Windows
if: ${{ matrix.os == 'windows-latest' }}
run: |
echo "RUST_TARGET=x86_64-pc-windows-msvc" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "RUST_TARGET_FILE=router.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "RUST_TARGET_OS=win" | Out-File -FilePath $env:GITHUB_ENV -Append
npm run cargo:fix
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Rust
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
with:
toolchain: '1.91.1'
target: ${{ env.RUST_TARGET }}
default: true
override: true
- name: Cache Rust
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
- name: Build
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
with:
command: build
args: --release
- name: Strip binary from debug symbols
if: ${{ matrix.os == 'ubuntu-latest' }}
run: strip target/release/${{ env.RUST_TARGET_FILE }}
- name: Compress
run: |
./target/release/compress ./target/release/${{ env.RUST_TARGET_FILE }} ./router.tar.gz
- name: Upload artifact
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
with:
name: router-${{ env.RUST_TARGET_OS }}
path: router.tar.gz
- name: Upload to R2 (${{ inputs.version }})
if: ${{ inputs.publish }}
uses: randomairborne/r2-release@9cbc35a2039ee2ef453a6988cd2a85bb2d7ba8af # v1.0.2
with:
endpoint: https://6d5bc18cd8d13babe7ed321adba3d8ae.r2.cloudflarestorage.com
accesskeyid: ${{ secrets.R2_ACCESS_KEY_ID }}
secretaccesskey: ${{ secrets.R2_SECRET_ACCESS_KEY }}
bucket: apollo-router
file: router.tar.gz
destination: ${{ inputs.version }}/${{ env.RUST_TARGET_OS }}/router.tar.gz
- name: Upload to R2 (latest)
if: ${{ inputs.publish && inputs.latest }}
uses: randomairborne/r2-release@9cbc35a2039ee2ef453a6988cd2a85bb2d7ba8af # v1.0.2
with:
endpoint: https://6d5bc18cd8d13babe7ed321adba3d8ae.r2.cloudflarestorage.com
accesskeyid: ${{ secrets.R2_ACCESS_KEY_ID }}
secretaccesskey: ${{ secrets.R2_SECRET_ACCESS_KEY }}
bucket: apollo-router
file: router.tar.gz
destination: latest/${{ env.RUST_TARGET_OS }}/router.tar.gz

View file

@ -13,7 +13,7 @@ jobs:
npmTag: alpha
buildScript: build:libraries
packageManager: pnpm
nodeVersion: '24.13'
nodeVersion: '24.14'
secrets:
githubToken: ${{ secrets.GITHUB_TOKEN }}
npmToken: ${{ secrets.NPM_TOKEN }}

View file

@ -118,11 +118,3 @@ jobs:
env:
VERSION: ${{ steps.cli.outputs.version }}
run: pnpm oclif promote --no-xz --sha ${GITHUB_SHA:0:7} --version $VERSION
- name: release to Crates.io
if:
steps.changesets.outputs.published && contains(steps.changesets.outputs.publishedPackages,
'"hive-apollo-router-plugin"')
run: |
cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }}
cargo publish --allow-dirty --no-verify

View file

@ -26,7 +26,7 @@ jobs:
fail-fast: false
matrix:
# Divide integration tests into 3 shards, to run them in parallel.
shardIndex: [1, 2, 3, 'apollo-router']
shardIndex: [1, 2, 3]
env:
DOCKER_REGISTRY: ${{ inputs.registry }}/${{ inputs.imageName }}/
@ -78,68 +78,7 @@ jobs:
run: |
docker compose -f docker/docker-compose.community.yml -f ./integration-tests/docker-compose.integration.yaml --env-file ./integration-tests/.env ps
## ---- START ---- Apollo Router specific steps
- if: matrix.shardIndex == 'apollo-router'
name: Install Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- if: matrix.shardIndex == 'apollo-router'
name: Install Rust
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
with:
toolchain: '1.91.1'
default: true
override: true
- if: matrix.shardIndex == 'apollo-router'
name: Cache Rust
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
# ---- START ---- Disk space cleanup before apollo router tests
- if: matrix.shardIndex == 'apollo-router'
name: Before cleanup disk space
run: df -h
- if: matrix.shardIndex == 'apollo-router'
name: Cleanup disk space
run: |
sudo rm -rf \
/usr/lib/jvm \
/usr/share/swift \
/usr/local/julia* \
/usr/local/share/chromium \
/opt/az \
/usr/local/share/powershell \
/opt/microsoft /opt/google \
/usr/local/lib/android \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/local/lib/android \
/opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
sudo docker builder prune -a
- if: matrix.shardIndex == 'apollo-router'
name: After cleanup disk space
run: df -h
# ---- END ---- Disk space cleanup before apollo router tests
- if: matrix.shardIndex == 'apollo-router'
name: build apollo router
run: |
cargo build
- if: matrix.shardIndex == 'apollo-router'
name: run apollo router integration tests
timeout-minutes: 30
run: |
pnpm test:integration:apollo-router
## ---- END ---- Apollo Router specific steps
- if: matrix.shardIndex != 'apollo-router'
name: run integration tests
- name: run integration tests
timeout-minutes: 30
run: |
pnpm test:integration --shard=${{ matrix.shardIndex }}/3

21
.gitignore vendored
View file

@ -110,9 +110,6 @@ integration-tests/testkit/gql/
npm-shrinkwrap.json
# Rust
/target
# bob
.bob/
@ -127,6 +124,7 @@ packages/web/app/next.config.mjs
packages/web/app/environment-*.mjs
packages/web/app/src/gql/*.ts
packages/web/app/src/gql/*.json
# Changelog
packages/web/app/src/components/ui/changelog/generated-changelog.ts
@ -142,20 +140,7 @@ resolvers.generated.ts
docker/docker-compose.override.yml
test-results/
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
Cargo.lock
target
Cargo.lock
Cargo.lock

View file

@ -1 +1 @@
24.13
24.14.1

7675
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
[workspace]
resolver = "2"
members = ["packages/libraries/router", "scripts/compress"]

7313
configs/cargo/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,115 @@
# hive
## 11.0.3
### Patch Changes
- [#7993](https://github.com/graphql-hive/console/pull/7993)
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-72c6-fx6q-fr5w](https://github.com/advisories/GHSA-72c6-fx6q-fr5w).
- [#7988](https://github.com/graphql-hive/console/pull/7988)
[`d7e7025`](https://github.com/graphql-hive/console/commit/d7e7025624ba66459515778c0724a58397a5f1b4)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-247c-9743-5963](https://github.com/advisories/GHSA-247c-9743-5963).
- [#7961](https://github.com/graphql-hive/console/pull/7961)
[`40fd27d`](https://github.com/graphql-hive/console/commit/40fd27d9c060df5417c18c750b02af65451e5323)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Update
[`nodemailer`](https://github.com/nodemailer/nodemailer) to address vulnerability
[GHSA-vvjj-xcjg-gr5g](https://github.com/advisories/GHSA-vvjj-xcjg-gr5g).
- [#7993](https://github.com/graphql-hive/console/pull/7993)
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-v9ww-2j6r-98q6](https://github.com/advisories/GHSA-v9ww-2j6r-98q6).
- [#7976](https://github.com/graphql-hive/console/pull/7976)
[`ed9ab34`](https://github.com/graphql-hive/console/commit/ed9ab34c705be4b7946dfcbede91926f00f1ed4a)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - address vulnerability
[GHSA-r4q5-vmmm-2653](https://github.com/advisories/GHSA-r4q5-vmmm-2653)
- [#7967](https://github.com/graphql-hive/console/pull/7967)
[`9708f71`](https://github.com/graphql-hive/console/commit/9708f71aa3e6dcd613f3877a0777c1e72710b200)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Fix schema contract composition applying
`@inaccessible` on the federation types `ContextArgument` and `FieldValue` on the supergraph SDL.
This mitigates the following error in apollo-router upon processing the supergraph:
```
could not create router: Api error(s): The supergraph schema failed to produce a valid API schema: The following errors occurred:
- Core feature type `join__ContextArgument` cannot use @inaccessible.
- Core feature type `join__FieldValue` cannot use @inaccessible.
```
- [#7993](https://github.com/graphql-hive/console/pull/7993)
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-xq3m-2v4x-88gg](https://github.com/advisories/GHSA-xq3m-2v4x-88gg).
- [#7978](https://github.com/graphql-hive/console/pull/7978)
[`9c6989c`](https://github.com/graphql-hive/console/commit/9c6989cd929df3b071cba2c5652cc18988127897)
Thanks [@jdolle](https://github.com/jdolle)! - Add schema linting support for type extensions
- [#7993](https://github.com/graphql-hive/console/pull/7993)
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[CVE-2026-6414](https://github.com/advisories/GHSA-x428-ghpx-8j92).
- [#7988](https://github.com/graphql-hive/console/pull/7988)
[`d7e7025`](https://github.com/graphql-hive/console/commit/d7e7025624ba66459515778c0724a58397a5f1b4)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-39q2-94rc-95cp](https://github.com/advisories/GHSA-39q2-94rc-95cp).
- [#7980](https://github.com/graphql-hive/console/pull/7980)
[`c46b2f2`](https://github.com/graphql-hive/console/commit/c46b2f221936ca60e49bce3a2fea25bb40378266)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-fvcv-3m26-pcqx](https://github.com/advisories/GHSA-fvcv-3m26-pcqx).
- [#7993](https://github.com/graphql-hive/console/pull/7993)
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[CVE-2026-6410](https://github.com/advisories/GHSA-pr96-94w5-mx2h).
- [#7961](https://github.com/graphql-hive/console/pull/7961)
[`40fd27d`](https://github.com/graphql-hive/console/commit/40fd27d9c060df5417c18c750b02af65451e5323)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Update
[`opentelemetry-go`](https://github.com/open-telemetry/opentelemetry-go) to address vulnerability
[CVE-2026-39883](https://github.com/advisories/GHSA-hfvc-g4fc-pqhx).
## 11.0.2
### Patch Changes
- [#7940](https://github.com/graphql-hive/console/pull/7940)
[`742f50c`](https://github.com/graphql-hive/console/commit/742f50c52e846ab63843635c6f408016a30f6288)
Thanks [@jdolle](https://github.com/jdolle)! - Fix lint policy block title color; add policy link
to lines and rule id tooltip
- [#7936](https://github.com/graphql-hive/console/pull/7936)
[`96bd390`](https://github.com/graphql-hive/console/commit/96bd390c7ffa75baf7db0c0bb3a3117c6ef6f631)
Thanks [@jdolle](https://github.com/jdolle)! - Do not cache edge types in graphql eslint. This
fixes an issue where edge types were cached between runs and only the cached edge types would be
referenced for subsequent runs
## 11.0.1
### Patch Changes
- [#7909](https://github.com/graphql-hive/console/pull/7909)
[`484054b`](https://github.com/graphql-hive/console/commit/484054be2773431a419be2c5ab16742f81d6b895)
Thanks [@jdolle](https://github.com/jdolle)! - Support project level token expiration
- [#7908](https://github.com/graphql-hive/console/pull/7908)
[`70b3e19`](https://github.com/graphql-hive/console/commit/70b3e19fe2e6e064f5693f8acfbfcae8182faac4)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Fix web app pagination for access tokens.
- [#7893](https://github.com/graphql-hive/console/pull/7893)
[`e5711a5`](https://github.com/graphql-hive/console/commit/e5711a52a87b9e66147de08378f8c3d17336d175)
Thanks [@jdolle](https://github.com/jdolle)! - Add expiration to all tokens; fix token ui spacing
issues
## 11.0.0
### Major Changes

View file

@ -32,7 +32,7 @@ async function generateVectorDevTypes() {
}
async function generateContourTypes() {
const helmValuesFileUrl = `https://raw.githubusercontent.com/bitnami/charts/contour/${CONTOUR_CHART.version}/bitnami/contour/values.yaml`;
const helmValuesFileUrl = `https://raw.githubusercontent.com/projectcontour/helm-charts/refs/tags/contour-${CONTOUR_CHART.version}/charts/contour/values.yaml`;
const valuesFile = await fetch(helmValuesFileUrl).then(r => r.text());
const valuesTempFile = fileSync();

View file

@ -1,6 +1,6 @@
{
"name": "hive",
"version": "11.0.0",
"version": "11.0.3",
"private": true,
"scripts": {
"generate": "tsx generate.ts",
@ -25,7 +25,7 @@
"@graphql-hive/gateway": "^2.1.19",
"@types/js-yaml": "4.0.9",
"@types/mime-types": "2.1.4",
"@types/node": "24.10.9",
"@types/node": "24.12.2",
"@types/tmp": "0.2.6",
"json-schema-to-typescript": "15.0.3",
"tmp": "0.2.5",

View file

@ -50,8 +50,8 @@ export function prepareEnvironment(input: {
},
envoy: {
replicas: isProduction || isStaging ? 3 : 1,
cpuLimit: isProduction ? '1500m' : '120m',
memoryLimit: isProduction ? '2Gi' : '200Mi',
cpuLimit: isProduction ? '1500m' : isStaging ? '300m' : '120m',
memoryLimit: isProduction || isStaging ? '2Gi' : '200Mi',
timeouts: {
idleTimeout: 905,
},
@ -60,29 +60,34 @@ export function prepareEnvironment(input: {
memoryLimit: isProduction || isStaging ? '3584Mi' : '1Gi',
},
usageService: {
replicas: isProduction || isStaging ? 3 : 1,
cpuLimit: isProduction ? '1000m' : '100m',
replicas: isProduction || isStaging ? 6 : 1,
cpuMin: isProduction || isStaging ? '200m' : '100m',
cpuMax: isProduction || isStaging ? '1000m' : '100m',
maxReplicas: isProduction || isStaging ? 6 : 1,
cpuAverageToScale: 60,
},
usageIngestorService: {
replicas: isProduction || isStaging ? 6 : 1,
cpuLimit: isProduction ? '1000m' : '100m',
cpuMax: isProduction || isStaging ? '1000m' : '100m',
cpuMin: isProduction || isStaging ? '300m' : '100m',
maxReplicas: isProduction || isStaging ? /* numberOfPartitions */ 16 : 2,
cpuAverageToScale: 60,
},
redis: {
memoryLimit: isProduction ? '4Gi' : '100Mi',
cpuLimit: isProduction ? '1000m' : '50m',
memoryLimit: isProduction || isStaging ? '4Gi' : '100Mi',
cpuMax: isProduction || isStaging ? '1000m' : '100m',
cpuMin: isProduction || isStaging ? '100m' : '50m',
},
internalObservability: {
cpuLimit: isProduction ? '512m' : '150m',
memoryLimit: isProduction ? '1000Mi' : '300Mi',
},
tracingCollector: {
cpuLimit: isProduction || isStaging ? '1000m' : '100m',
cpuMax: isProduction || isStaging ? '1000m' : '300m',
cpuMin: '100m',
memoryLimit: isProduction || isStaging ? '1000Mi' : '512Mi',
maxReplicas: isProduction || isStaging ? 3 : 1,
replicas: 1,
},
},
};

View file

@ -36,17 +36,20 @@ export function deployOTELCollector(args: {
livenessProbe: '/',
startupProbe: '/',
exposesMetrics: true,
replicas: args.environment.podsConfig.tracingCollector.maxReplicas,
replicas: args.environment.podsConfig.tracingCollector.replicas,
pdb: true,
availabilityOnEveryNode: true,
port: 4318,
memoryLimit: args.environment.podsConfig.tracingCollector.memoryLimit,
memory: {
limit: args.environment.podsConfig.tracingCollector.memoryLimit,
},
cpu: {
limit: args.environment.podsConfig.tracingCollector.cpuMax,
requests: args.environment.podsConfig.tracingCollector.cpuMin,
},
autoScaling: {
maxReplicas: args.environment.podsConfig.tracingCollector.maxReplicas,
cpu: {
limit: args.environment.podsConfig.tracingCollector.cpuLimit,
cpuAverageToScale: 80,
},
cpuAverageToScale: 80,
},
},
[args.clickhouse.deployment, args.clickhouse.service, args.dbMigrations],

View file

@ -96,16 +96,13 @@ export function deployProxy({
service: graphql.service,
requestTimeout: '60s',
retriable: true,
rateLimit: {
maxRequests: 10,
unit: 'minute',
},
},
{
name: 'usage',
path: '/usage',
service: usage.service,
retriable: true,
loadBalancerPolicy: 'WeightedLeastRequest',
},
])
.registerService({ record: environment.apiDns }, [

View file

@ -21,7 +21,10 @@ export function deployRedis(input: { environment: Environment }) {
}).deploy({
limits: {
memory: input.environment.podsConfig.redis.memoryLimit,
cpu: input.environment.podsConfig.redis.cpuLimit,
cpu: input.environment.podsConfig.redis.cpuMax,
},
requests: {
cpu: input.environment.podsConfig.redis.cpuMin,
},
});

View file

@ -53,7 +53,9 @@ export function deploySchema({
startupProbe: '/_health',
exposesMetrics: true,
replicas: environment.podsConfig.general.replicas,
memoryLimit: environment.podsConfig.schemaService.memoryLimit,
memory: {
limit: environment.podsConfig.schemaService.memoryLimit,
},
pdb: true,
},
[redis.deployment, redis.service],

View file

@ -61,11 +61,12 @@ export function deployUsageIngestor({
exposesMetrics: true,
port: 4000,
pdb: true,
cpu: {
limit: environment.podsConfig.usageIngestorService.cpuMax,
requests: environment.podsConfig.usageIngestorService.cpuMin,
},
autoScaling: {
cpu: {
cpuAverageToScale: environment.podsConfig.usageIngestorService.cpuAverageToScale,
limit: environment.podsConfig.usageIngestorService.cpuLimit,
},
cpuAverageToScale: environment.podsConfig.usageIngestorService.cpuAverageToScale,
maxReplicas: environment.podsConfig.usageIngestorService.maxReplicas,
},
},

View file

@ -81,11 +81,12 @@ export function deployUsage({
exposesMetrics: true,
port: 4000,
pdb: true,
cpu: {
limit: environment.podsConfig.usageService.cpuMax,
requests: environment.podsConfig.usageService.cpuMin,
},
autoScaling: {
cpu: {
cpuAverageToScale: environment.podsConfig.usageService.cpuAverageToScale,
limit: environment.podsConfig.usageService.cpuLimit,
},
cpuAverageToScale: environment.podsConfig.usageService.cpuAverageToScale,
maxReplicas: environment.podsConfig.usageService.maxReplicas,
},
},

View file

@ -263,168 +263,6 @@ export interface ContourValues {
};
[k: string]: unknown;
};
defaultBackend?: {
affinity?: {
[k: string]: unknown;
};
args?: unknown[];
command?: unknown[];
containerPorts?: {
http?: number;
[k: string]: unknown;
};
containerSecurityContext?: {
allowPrivilegeEscalation?: boolean;
capabilities?: {
drop?: string[];
[k: string]: unknown;
};
enabled?: boolean;
privileged?: boolean;
readOnlyRootFilesystem?: boolean;
runAsGroup?: number;
runAsNonRoot?: boolean;
runAsUser?: number;
seLinuxOptions?: {
[k: string]: unknown;
};
seccompProfile?: {
type?: string;
[k: string]: unknown;
};
[k: string]: unknown;
};
customLivenessProbe?: {
[k: string]: unknown;
};
customReadinessProbe?: {
[k: string]: unknown;
};
customStartupProbe?: {
[k: string]: unknown;
};
enabled?: boolean;
extraArgs?: {
[k: string]: unknown;
};
extraEnvVars?: unknown[];
extraEnvVarsCM?: string;
extraEnvVarsSecret?: string;
extraVolumeMounts?: unknown[];
extraVolumes?: unknown[];
hostAliases?: unknown[];
image?: {
digest?: string;
pullPolicy?: string;
pullSecrets?: unknown[];
registry?: string;
repository?: string;
tag?: string;
[k: string]: unknown;
};
initContainers?: unknown[];
lifecycleHooks?: {
[k: string]: unknown;
};
livenessProbe?: {
enabled?: boolean;
failureThreshold?: number;
initialDelaySeconds?: number;
periodSeconds?: number;
successThreshold?: number;
timeoutSeconds?: number;
[k: string]: unknown;
};
networkPolicy?: {
allowExternal?: boolean;
allowExternalEgress?: boolean;
enabled?: boolean;
extraEgress?: unknown[];
extraIngress?: unknown[];
ingressNSMatchLabels?: {
[k: string]: unknown;
};
ingressNSPodMatchLabels?: {
[k: string]: unknown;
};
[k: string]: unknown;
};
nodeAffinityPreset?: {
key?: string;
type?: string;
values?: unknown[];
[k: string]: unknown;
};
nodeSelector?: {
[k: string]: unknown;
};
pdb?: {
create?: boolean;
maxUnavailable?: string;
minAvailable?: string;
[k: string]: unknown;
};
podAffinityPreset?: string;
podAnnotations?: {
[k: string]: unknown;
};
podAntiAffinityPreset?: string;
podLabels?: {
[k: string]: unknown;
};
podSecurityContext?: {
enabled?: boolean;
fsGroup?: number;
fsGroupChangePolicy?: string;
supplementalGroups?: unknown[];
sysctls?: unknown[];
[k: string]: unknown;
};
priorityClassName?: string;
readinessProbe?: {
enabled?: boolean;
failureThreshold?: number;
initialDelaySeconds?: number;
periodSeconds?: number;
successThreshold?: number;
timeoutSeconds?: number;
[k: string]: unknown;
};
replicaCount?: number;
resources?: {
[k: string]: unknown;
};
resourcesPreset?: string;
schedulerName?: string;
service?: {
annotations?: {
[k: string]: unknown;
};
ports?: {
http?: number;
[k: string]: unknown;
};
type?: string;
[k: string]: unknown;
};
sidecars?: unknown[];
startupProbe?: {
enabled?: boolean;
failureThreshold?: number;
initialDelaySeconds?: number;
periodSeconds?: number;
successThreshold?: number;
timeoutSeconds?: number;
[k: string]: unknown;
};
terminationGracePeriodSeconds?: number;
tolerations?: unknown[];
topologySpreadConstraints?: unknown[];
updateStrategy?: {
[k: string]: unknown;
};
[k: string]: unknown;
};
diagnosticMode?: {
args?: number[];
command?: string[];
@ -485,6 +323,37 @@ export interface ContourValues {
customStartupProbe?: {
[k: string]: unknown;
};
defaultInitContainers?: {
initConfig?: {
containerSecurityContext?: {
allowPrivilegeEscalation?: boolean;
capabilities?: {
drop?: string[];
[k: string]: unknown;
};
enabled?: boolean;
privileged?: boolean;
readOnlyRootFilesystem?: boolean;
runAsGroup?: number;
runAsNonRoot?: boolean;
runAsUser?: number;
seLinuxOptions?: {
[k: string]: unknown;
};
seccompProfile?: {
type?: string;
[k: string]: unknown;
};
[k: string]: unknown;
};
resources?: {
[k: string]: unknown;
};
resourcesPreset?: string;
[k: string]: unknown;
};
[k: string]: unknown;
};
dnsPolicy?: string;
enabled?: boolean;
extraArgs?: unknown[];
@ -516,30 +385,6 @@ export interface ContourValues {
tag?: string;
[k: string]: unknown;
};
initConfig?: {
containerSecurityContext?: {
allowPrivilegeEscalation?: boolean;
capabilities?: {
drop?: string[];
[k: string]: unknown;
};
enabled?: boolean;
privileged?: boolean;
readOnlyRootFilesystem?: boolean;
runAsGroup?: number;
runAsNonRoot?: boolean;
runAsUser?: number;
seLinuxOptions?: {
[k: string]: unknown;
};
seccompProfile?: {
type?: string;
[k: string]: unknown;
};
[k: string]: unknown;
};
[k: string]: unknown;
};
initContainers?: unknown[];
kind?: string;
lifecycleHooks?: {
@ -796,34 +641,9 @@ export interface ContourValues {
defaultStorageClass?: string;
imagePullSecrets?: unknown[];
imageRegistry?: string;
security?: {
allowInsecureImages?: boolean;
[k: string]: unknown;
};
storageClass?: string;
[k: string]: unknown;
};
ingress?: {
annotations?: {
[k: string]: unknown;
};
apiVersion?: string;
certManager?: boolean;
enabled?: boolean;
extraHosts?: unknown[];
extraPaths?: unknown[];
extraRules?: unknown[];
extraTls?: unknown[];
hostname?: string;
ingressClassName?: string;
path?: string;
pathType?: string;
rulesOverride?: unknown[];
secrets?: unknown[];
selfSigned?: boolean;
tls?: boolean;
[k: string]: unknown;
};
kubeVersion?: string;
metrics?: {
prometheusRule?: {

View file

@ -314,7 +314,7 @@ export class Observability {
{
role: 'pod',
namespaces: {
names: ['default'],
names: ['default', 'contour'],
},
},
],

View file

@ -18,13 +18,16 @@ export class Redis {
},
) {}
deploy(input: { limits: { memory: string; cpu: string } }) {
deploy(input: { limits: { memory: string; cpu: string }; requests: { cpu: string } }) {
const redisService = getLocalComposeConfig().service('redis');
const name = 'redis-store';
const limits: k8s.types.input.core.v1.ResourceRequirements['limits'] = {
memory: input.limits.memory,
cpu: input.limits.cpu,
};
const requests: k8s.types.input.core.v1.ResourceRequirements['requests'] = {
cpu: input.requests.cpu,
};
const env: k8s.types.input.core.v1.EnvVar[] = normalizeEnv(this.options.env ?? {}).concat([
{
@ -100,6 +103,7 @@ export class Redis {
ports: [{ containerPort: REDIS_PORT, protocol: 'TCP' }],
resources: {
limits,
requests,
},
livenessProbe: {
initialDelaySeconds: 3,
@ -139,6 +143,10 @@ export class Redis {
cpu: '200m',
memory: '200Mi',
},
requests: {
cpu: '100m',
memory: '100Mi',
},
},
},
],

View file

@ -4,7 +4,7 @@ import { ContourValues } from './contour.types';
import { helmChart } from './helm';
// prettier-ignore
export const CONTOUR_CHART = helmChart('https://raw.githubusercontent.com/bitnami/charts/refs/heads/index/bitnami/', 'contour', '20.0.3');
export const CONTOUR_CHART = helmChart('https://projectcontour.github.io/helm-charts/', 'contour', '0.4.0');
export class Proxy {
private lbService: Output<k8s.core.v1.Service> | null = null;
@ -84,23 +84,13 @@ export class Proxy {
requestTimeout?: `${number}s` | 'infinity';
idleTimeout?: `${number}s`;
retriable?: boolean;
loadBalancerPolicy?:
| 'WeightedLeastRequest'
| 'RoundRobin'
| 'Random'
| 'RequestHash'
| 'Cookie';
customRewrite?: string;
virtualHost?: Output<string>;
httpsUpstream?: boolean;
withWwwDomain?: boolean;
// https://projectcontour.io/docs/1.29/config/rate-limiting/#local-rate-limiting
rateLimit?: {
// Max amount of request allowed with the "unit" parameter.
maxRequests: number;
unit: 'second' | 'minute' | 'hour';
// defining the number of requests above the baseline rate that are allowed in a short period of time.
// This would allow occasional larger bursts of traffic not to be rate limited.
burst?: number;
// default 429
responseStatusCode?: number;
// headers to add to the response in case of a rate limit
responseHeadersToAdd?: Record<string, string>;
};
}[],
) {
const cert = new k8s.apiextensions.CustomResource(`cert-${dns.record}`, {
@ -153,32 +143,10 @@ export class Proxy {
port: route.service.spec.ports[0].port,
},
],
// https://projectcontour.io/docs/1.29/config/request-routing/#session-affinity
// https://projectcontour.io/docs/1.33/config/request-routing/
loadBalancerPolicy: {
strategy: 'Cookie',
strategy: route.loadBalancerPolicy ?? 'RoundRobin',
},
// https://projectcontour.io/docs/1.29/config/rate-limiting/#local-rate-limiting
rateLimitPolicy: route.rateLimit
? {
local: {
requests: route.rateLimit.maxRequests,
unit: route.rateLimit.unit,
responseHeadersToAdd: [
{
name: 'x-rate-limit-active',
value: 'true',
},
...(route.rateLimit.responseHeadersToAdd
? Object.entries(route.rateLimit.responseHeadersToAdd).map(
([key, value]) => ({ name: key, value }),
)
: []),
],
responseStatusCode: route.rateLimit.responseStatusCode || 429,
burst: route.rateLimit.burst,
},
}
: undefined,
...(route.path === '/'
? {}
: {
@ -312,16 +280,7 @@ export class Proxy {
}
: {}),
},
// Needed because we override the `contour.image.repository` field.
global: {
security: {
allowInsecureImages: true,
},
},
contour: {
image: {
repository: 'bitnamilegacy/contour',
},
podAnnotations: {
'prometheus.io/scrape': 'true',
'prometheus.io/port': '8000',
@ -331,14 +290,13 @@ export class Proxy {
podLabels: {
'vector.dev/exclude': 'true',
},
// Placeholder, see below
resources: {
limits: {},
},
},
envoy: {
image: {
repository: 'bitnamilegacy/envoy',
},
// Placeholder, see below
resources: {
limits: {},
},
@ -380,7 +338,7 @@ export class Proxy {
const proxyController = new k8s.helm.v3.Chart('contour-proxy', {
...CONTOUR_CHART,
namespace: ns.metadata.name,
// https://github.com/bitnami/charts/tree/master/bitnami/contour
// https://artifacthub.io/packages/helm/contour/contour
values: chartValues,
});

View file

@ -46,7 +46,14 @@ export class ServiceDeployment {
livenessProbe?: string | ProbeConfig;
readinessProbe?: string | ProbeConfig;
startupProbe?: string | ProbeConfig;
memoryLimit?: string;
memory?: {
limit?: string;
requests?: string;
};
cpu?: {
limit?: string;
requests?: string;
};
volumes?: k8s.types.input.core.v1.Volume[];
volumeMounts?: k8s.types.input.core.v1.VolumeMount[];
/**
@ -58,10 +65,7 @@ export class ServiceDeployment {
autoScaling?: {
minReplicas?: number;
maxReplicas: number;
cpu: {
limit: string;
cpuAverageToScale: number;
};
cpuAverageToScale: number;
};
availabilityOnEveryNode?: boolean;
command?: pulumi.Input<pulumi.Input<string>[]>;
@ -205,14 +209,23 @@ export class ServiceDeployment {
});
}
const resourcesLimits: Record<string, string> = {};
const resourcesLimits: { cpu?: string; memory?: string } = {};
const resourcesRequests: { cpu?: string; memory?: string } = {};
if (this.options?.autoScaling?.cpu.limit) {
resourcesLimits.cpu = this.options.autoScaling.cpu.limit;
if (this.options?.cpu?.limit) {
resourcesLimits.cpu = this.options?.cpu?.limit;
}
if (this.options.memoryLimit) {
resourcesLimits.memory = this.options.memoryLimit;
if (this.options?.cpu?.requests) {
resourcesRequests.cpu = this.options?.cpu?.requests;
}
if (this.options?.memory?.limit) {
resourcesLimits.memory = this.options?.memory?.limit;
}
if (this.options?.memory?.requests) {
resourcesRequests.memory = this.options?.memory?.requests;
}
const pb = new PodBuilder({
@ -248,6 +261,7 @@ export class ServiceDeployment {
image: this.options.image,
resources: {
limits: resourcesLimits,
requests: resourcesRequests,
},
args: this.options.args,
ports: {
@ -342,7 +356,7 @@ export class ServiceDeployment {
name: 'cpu',
target: {
type: 'Utilization',
averageUtilization: this.options.autoScaling.cpu.cpuAverageToScale,
averageUtilization: this.options.autoScaling.cpuAverageToScale,
},
},
},

View file

@ -1,4 +1,4 @@
FROM node:24.13.0-slim
FROM node:24.14.1-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*

View file

@ -12,8 +12,8 @@ require (
go.opentelemetry.io/collector/extension v1.53.0
go.opentelemetry.io/collector/extension/extensionauth v1.53.0
go.opentelemetry.io/collector/extension/extensiontest v0.147.0
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/metric v1.40.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/metric v1.43.0
go.uber.org/goleak v1.3.0
go.uber.org/zap v1.27.1
golang.org/x/sync v0.19.0
@ -41,13 +41,13 @@ require (
go.opentelemetry.io/collector/featuregate v1.53.0 // indirect
go.opentelemetry.io/collector/internal/componentalias v0.147.0 // indirect
go.opentelemetry.io/collector/pdata v1.53.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.79.3 // indirect

View file

@ -79,14 +79,24 @@ go.opentelemetry.io/collector/pdata v1.53.0 h1:DlYDbRwammEZaxDZHINx5v0n8SEOVNniP
go.opentelemetry.io/collector/pdata v1.53.0/go.mod h1:LRSYGNjKXaUrZEwZv3Yl+8/zV2HmRGKXW62zB2bysms=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE=
go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI=
go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8=
@ -107,6 +117,8 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=

View file

@ -337,25 +337,6 @@ target "app" {
]
}
target "apollo-router" {
inherits = ["router-base", get_target()]
contexts = {
router_pkg = "${PWD}/packages/libraries/router"
config = "${PWD}/configs/cargo"
}
args = {
IMAGE_TITLE = "graphql-hive/apollo-router"
PORT = "4000"
IMAGE_DESCRIPTION = "Apollo Router for GraphQL Hive."
}
tags = [
local_image_tag("apollo-router"),
stable_image_tag("apollo-router"),
image_tag("apollo-router", COMMIT_SHA),
image_tag("apollo-router", BRANCH_NAME)
]
}
target "otel-collector" {
inherits = ["otel-collector-base", get_target()]
context = "${PWD}/docker/configs/otel-collector"
@ -421,12 +402,6 @@ group "integration-tests" {
]
}
group "apollo-router-hive-build" {
targets = [
"apollo-router"
]
}
group "cli" {
targets = [
"cli"

View file

@ -1,4 +1,4 @@
FROM node:24.13.0-slim
FROM node:24.14.1-slim
RUN apt-get update && apt-get install -y ca-certificates

View file

@ -1,62 +0,0 @@
# syntax=docker/dockerfile:1
FROM scratch AS router_pkg
FROM scratch AS config
FROM rust:1.91.1-slim-bookworm AS build
# Required by Apollo Router
RUN apt-get update
RUN apt-get -y install npm protobuf-compiler cmake
RUN rm -rf /var/lib/apt/lists/*
RUN update-ca-certificates
RUN rustup component add rustfmt
WORKDIR /usr/src
# Create blank projects
RUN USER=root cargo new router
# Copy Cargo files
COPY --from=router_pkg Cargo.toml /usr/src/router/
COPY --from=config Cargo.lock /usr/src/router/
WORKDIR /usr/src/router
# Get the dependencies cached, so we can use dummy input files so Cargo wont fail
RUN echo 'fn main() { println!(""); }' > ./src/main.rs
RUN echo 'fn main() { println!(""); }' > ./src/lib.rs
RUN cargo build --release
# Copy in the actual source code
COPY --from=router_pkg src ./src
RUN touch ./src/main.rs
RUN touch ./src/lib.rs
# Real build this time
RUN cargo build --release
# Runtime
FROM debian:bookworm-slim AS runtime
RUN apt-get update
RUN apt-get -y install ca-certificates
RUN rm -rf /var/lib/apt/lists/*
LABEL org.opencontainers.image.title=$IMAGE_TITLE
LABEL org.opencontainers.image.version=$RELEASE
LABEL org.opencontainers.image.description=$IMAGE_DESCRIPTION
LABEL org.opencontainers.image.authors="The Guild"
LABEL org.opencontainers.image.vendor="Kamil Kisiela"
LABEL org.opencontainers.image.url="https://github.com/graphql-hive/console"
LABEL org.opencontainers.image.source="https://github.com/graphql-hive/console"
RUN mkdir -p /dist/config
RUN mkdir /dist/schema
# Copy in the required files from our build image
COPY --from=build --chown=root:root /usr/src/router/target/release/router /dist
COPY --from=router_pkg router.yaml /dist/config/router.yaml
WORKDIR /dist
ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config/router.yaml"
ENTRYPOINT ["./router"]

View file

@ -1,4 +1,4 @@
FROM node:24.13.0-slim
FROM node:24.14.1-slim
RUN apt-get update && apt-get install -y wget ca-certificates && rm -rf /var/lib/apt/lists/*

View file

@ -12,7 +12,7 @@ networks:
services:
local_cdn:
image: node:24.13.0-alpine3.23
image: node:24.14.1-alpine3.23
working_dir: /app
command: ['node', 'index.js']
networks:
@ -38,7 +38,7 @@ services:
S3_BUCKET_NAME: artifacts
local_broker:
image: node:24.13.0-alpine3.23
image: node:24.14.1-alpine3.23
working_dir: /app
command: ['node', 'broker.js']
networks:
@ -90,7 +90,7 @@ services:
SECRET: '${EXTERNAL_COMPOSITION_SECRET}'
external_composition:
image: node:24.13.0-alpine3.23
image: node:24.14.1-alpine3.23
working_dir: /app
command: ['node', 'example.mjs']
networks:
@ -290,10 +290,10 @@ services:
# It's not part of integration tests
app:
image: node:24.13.0-alpine3.23
image: node:24.14.1-alpine3.23
command: ['npx', 'http-server']
# Redpand is used for integration tests, instead of Kafka. Zookeeper is no longer needed
zookeeper:
image: node:24.13.0-alpine3.23
image: node:24.14.1-alpine3.23
command: ['npx', 'http-server']

View file

@ -7,12 +7,11 @@
"prepare:env": "cd ../ && pnpm build:libraries && pnpm build:services",
"start": "./local.sh",
"test:integration": "vitest",
"test:integration:apollo-router": "TEST_APOLLO_ROUTER=1 vitest tests/apollo-router",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@apollo/gateway": "2.13.2",
"@apollo/server": "5.4.0",
"@apollo/server": "5.5.0",
"@apollo/subgraph": "2.13.2",
"@aws-sdk/client-s3": "3.723.0",
"@esm2cjs/execa": "6.1.1-cjs.1",
@ -20,11 +19,12 @@
"@graphql-hive/core": "workspace:*",
"@graphql-typed-document-node/core": "3.2.0",
"@hive/commerce": "workspace:*",
"@hive/postgres": "workspace:*",
"@hive/schema": "workspace:*",
"@hive/server": "workspace:*",
"@hive/service-common": "workspace:*",
"@hive/storage": "workspace:*",
"@theguild/federation-composition": "0.22.1",
"@theguild/federation-composition": "0.22.2",
"@trpc/client": "10.45.3",
"@trpc/server": "10.45.3",
"@types/async-retry": "1.4.8",
@ -42,10 +42,9 @@
"human-id": "4.1.1",
"ioredis": "5.8.2",
"set-cookie-parser": "2.7.1",
"slonik": "30.4.4",
"strip-ansi": "7.1.2",
"tslib": "2.8.1",
"vitest": "4.0.9",
"vitest": "4.1.3",
"zod": "3.25.76"
}
}

View file

@ -1,10 +1,10 @@
import { DatabasePool } from 'slonik';
import {
AccessTokenKeyContainer,
hashPassword,
} from '@hive/api/modules/auth/lib/supertokens-at-home/crypto';
import { SuperTokensStore } from '@hive/api/modules/auth/providers/supertokens-store';
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
import { PostgresDatabasePool } from '@hive/postgres';
import type { InternalApi } from '@hive/server';
import { createNewSession } from '@hive/server/supertokens-at-home/shared';
import { createTRPCProxyClient, httpLink } from '@trpc/client';
@ -76,7 +76,7 @@ const tokenResponsePromise: {
} = {};
export async function authenticate(
pool: DatabasePool,
pool: PostgresDatabasePool,
email: string,
oidcIntegrationId?: string,
): Promise<{ access_token: string; refresh_token: string; supertokensUserId: string }> {

View file

@ -1,9 +1,9 @@
import type { AddressInfo } from 'node:net';
import humanId from 'human-id';
import setCookie from 'set-cookie-parser';
import { sql, type DatabasePool } from 'slonik';
import z from 'zod';
import formDataPlugin from '@fastify/formbody';
import { psql, type PostgresDatabasePool } from '@hive/postgres';
import { createServer, type FastifyReply, type FastifyRequest } from '@hive/service-common';
import { graphql } from './gql';
import { execute } from './graphql';
@ -157,7 +157,7 @@ const VerifyEmailMutation = graphql(`
export async function createOIDCIntegration(args: {
organizationId: string;
accessToken: string;
getPool: () => Promise<DatabasePool>;
getPool: () => Promise<PostgresDatabasePool>;
}) {
const { accessToken: authToken, getPool } = args;
const result = await execute({
@ -192,7 +192,7 @@ export async function createOIDCIntegration(args: {
}) + '.local';
const pool = await getPool();
const query = sql`
const query = psql`
INSERT INTO "oidc_integration_domains" (
"organization_id"
, "oidc_integration_id"

View file

@ -1,8 +1,9 @@
import { formatISO, subHours } from 'date-fns';
import { humanId } from 'human-id';
import { createPool, sql } from 'slonik';
import z from 'zod';
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
import { createRedisClient } from '@hive/api/modules/shared/providers/redis';
import { createPostgresDatabasePool, psql } from '@hive/postgres';
import type { Report } from '../../packages/libraries/core/src/client/usage.js';
import { authenticate, userEmail } from './auth';
import {
@ -53,7 +54,12 @@ import {
updateTargetValidationSettings,
} from './flow';
import * as GraphQLSchema from './gql/graphql';
import { ProjectType, SchemaPolicyInput, TargetAccessScope } from './gql/graphql';
import {
ProjectType,
SchemaPolicyInput,
TargetAccessScope,
UpdateOrgRateLimitDocument,
} from './gql/graphql';
import { execute } from './graphql';
import { createOIDCIntegration } from './oidc-integration.js';
import {
@ -68,7 +74,7 @@ import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from
import { collect, CollectedOperation, legacyCollect } from './usage';
import { generateUnique, getServiceHost, pollForEmailVerificationLink } from './utils';
function createConnectionPool() {
function getPGConnectionString() {
const pg = {
user: ensureEnv('POSTGRES_USER'),
password: ensureEnv('POSTGRES_PASSWORD'),
@ -77,9 +83,13 @@ function createConnectionPool() {
db: ensureEnv('POSTGRES_DB'),
};
return createPool(
`postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`,
);
return `postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`;
}
function createConnectionPool() {
return createPostgresDatabasePool({
connectionParameters: getPGConnectionString(),
});
}
async function createDbConnection() {
@ -92,9 +102,9 @@ async function createDbConnection() {
};
}
export function initSeed() {
let sharedDBPoolPromise: ReturnType<typeof createDbConnection>;
let sharedDBPoolPromise: ReturnType<typeof createDbConnection>;
export function initSeed() {
function getPool() {
if (!sharedDBPoolPromise) {
sharedDBPoolPromise = createDbConnection();
@ -113,7 +123,7 @@ export function initSeed() {
if (opts?.verifyEmail ?? true) {
const pool = await getPool();
await pool.query(sql`
await pool.query(psql`
INSERT INTO "email_verifications" ("user_identity_id", "email", "verified_at")
VALUES (${auth.supertokensUserId}, ${email}, NOW())
`);
@ -122,22 +132,38 @@ export function initSeed() {
return auth;
}
async function purgeOrganizationAccessTokenById(id: string) {
const registryAddress = await getServiceHost('server', 8082);
const purged: { deleted: boolean } = await fetch(
'http://' + registryAddress + '/cache/organization-access-token-cache/delete/' + id,
{
method: 'POST',
},
).then(res => res.json());
expect(purged.deleted).toBe(true);
}
return {
pollForEmailVerificationLink,
getPGConnectionString,
async purgeOIDCDomains() {
const pool = await getPool();
await pool.query(sql`
await pool.query(psql`
TRUNCATE "oidc_integration_domains"
`);
},
async forgeOIDCDNSChallenge(orgSlug: string) {
const pool = await getPool();
const domainChallengeId = await pool.oneFirst<string>(sql`
const domainChallengeId = await pool
.oneFirst(
psql`
SELECT "oidc_integration_domains"."id"
FROM "oidc_integration_domains" INNER JOIN "organizations" ON "oidc_integration_domains"."organization_id" = "organizations"."id"
WHERE "organizations"."clean_id" = ${orgSlug}
`);
`,
)
.then(z.string().parse);
const key = `hive:oidcDomainChallenge:${domainChallengeId}`;
const challenge = {
@ -164,15 +190,7 @@ export function initSeed() {
createDbConnection,
authenticate: doAuthenticate,
generateEmail: () => userEmail(generateUnique()),
async purgeOrganizationAccessTokenById(id: string) {
const registryAddress = await getServiceHost('server', 8082);
await fetch(
'http://' + registryAddress + '/cache/organization-access-token-cache/delete/' + id,
{
method: 'POST',
},
).then(res => res.json());
},
purgeOrganizationAccessTokenById,
async createOwner(verifyEmail: boolean = true) {
const ownerEmail = userEmail(generateUnique());
const auth = await doAuthenticate(ownerEmail, {
@ -197,6 +215,31 @@ export function initSeed() {
return {
organization,
async overrideOrgPlan(plan: 'PRO' | 'ENTERPRISE' | 'HOBBY') {
const pool = await createConnectionPool();
await pool.query(psql`
UPDATE organizations SET plan_name = ${plan} WHERE id = ${organization.id}
`);
await pool.end();
},
async updateOrgRateLimit(newLimit: number, token = ownerToken) {
const result = await execute({
document: UpdateOrgRateLimitDocument,
variables: {
selector: {
organizationSlug: organization.slug,
},
monthlyLimits: {
operations: newLimit,
},
},
authToken: token,
}).then(r => r.expectNoGraphQLErrors());
return result.updateOrgRateLimit;
},
async createOrganizationAccessToken(
args: {
permissions: Array<string>;
@ -227,8 +270,8 @@ export function initSeed() {
async setFeatureFlag(name: string, value: boolean | string[]) {
const pool = await createConnectionPool();
await pool.query(sql`
UPDATE organizations SET feature_flags = ${sql.jsonb({
await pool.query(psql`
UPDATE organizations SET feature_flags = ${psql.jsonb({
[name]: value,
})}
WHERE id = ${organization.id}
@ -239,7 +282,7 @@ export function initSeed() {
async setDataRetention(days: number) {
const pool = await createConnectionPool();
await pool.query(sql`
await pool.query(psql`
UPDATE organizations SET limit_retention_days = ${days} WHERE id = ${organization.id}
`);
@ -304,6 +347,22 @@ export function initSeed() {
return members;
},
/** Expires tokens */
async forceExpireTokens(tokenIds: string[]) {
const pool = await createConnectionPool();
const result = await pool.any(psql`
UPDATE "organization_access_tokens"
SET "expires_at" = NOW()
WHERE id IN (${psql.join(tokenIds, psql.fragment`, `)}) AND organization_id = ${organization.id}
RETURNING
"id"
`);
await pool.end();
expect(result.length).toBe(tokenIds.length);
for (const id of tokenIds) {
await purgeOrganizationAccessTokenById(id);
}
},
async projects(token = ownerToken) {
const projectsResult = await getOrganizationProjects(
{ organizationSlug: organization.slug },
@ -343,7 +402,7 @@ export function initSeed() {
async setNativeFederation(enabled: boolean) {
const pool = await createConnectionPool();
await pool.query(sql`
await pool.query(psql`
UPDATE projects SET native_federation = ${enabled} WHERE id = ${project.id}
`);

View file

@ -471,3 +471,53 @@ test.concurrent(
).toHaveLength(1);
},
);
test.concurrent('query GraphQL API on resources after token expiration', async ({ expect }) => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const org = await createOrg();
const project = await org.createProject(GraphQLSchema.ProjectType.Federation);
const result = await createOrganizationAccessToken(
{
organization: {
byId: org.organization.id,
},
title: 'second access token',
description: 'a description',
resources: {
mode: GraphQLSchema.ResourceAssignmentModeType.All,
},
permissions: ['organization:describe'],
},
ownerToken,
).then(e => e.expectNoGraphQLErrors());
expect(result.createOrganizationAccessToken.error).toEqual(null);
const orgAccessToken = result.createOrganizationAccessToken.ok?.privateAccessKey;
expect(result.createOrganizationAccessToken.ok?.createdOrganizationAccessToken.id).toBeDefined();
await execute({
document: OrganizationProjectTargetQuery,
variables: {
organizationSlug: org.organization.slug,
projectSlug: project.project.slug,
targetSlug: project.target.slug,
},
authToken: orgAccessToken,
}).then(e => e.expectNoGraphQLErrors());
await org.forceExpireTokens([
result.createOrganizationAccessToken.ok?.createdOrganizationAccessToken.id!,
]);
const expiredResult = await execute({
document: OrganizationProjectTargetQuery,
variables: {
organizationSlug: org.organization.slug,
projectSlug: project.project.slug,
targetSlug: project.target.slug,
},
authToken: orgAccessToken,
}).then(e => e.expectGraphQLErrors());
const error = expiredResult[0];
expect(error.message).toEqual('Invalid token provided');
});

View file

@ -746,3 +746,69 @@ test.concurrent(
});
},
);
test.concurrent('query GraphQL API on resources after token expiration', async ({ expect }) => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const org = await createOrg();
const project1 = await org.createProject(GraphQLSchema.ProjectType.Federation);
const project2 = await org.createProject(GraphQLSchema.ProjectType.Federation);
const result = await execute({
document: CreatePersonalAccessTokenMutation,
variables: {
input: {
organization: {
byId: org.organization.id,
},
title: 'an access token',
description: 'a description',
resources: {
mode: GraphQLSchema.ResourceAssignmentModeType.Granular,
projects: [
{
projectId: project1.project.id,
targets: { mode: GraphQLSchema.ResourceAssignmentModeType.All },
},
],
},
expirationPeriod: GraphQLSchema.TokenExpirationPeriod.OneWeek,
permissions: ['organization:describe', 'project:describe'],
},
},
authToken: ownerToken,
}).then(e => e.expectNoGraphQLErrors());
expect(result.createPersonalAccessToken.error).toEqual(null);
assertNonNullish(result.createPersonalAccessToken.ok);
const personalAccessToken = result.createPersonalAccessToken.ok.privateAccessKey;
const projectQuery = await execute({
document: OrganizationProjectTargetQuery1,
variables: {
organizationSlug: org.organization.slug,
projectSlug: project2.project.slug,
targetSlug: project2.target.slug,
},
authToken: personalAccessToken,
}).then(e => e.expectNoGraphQLErrors());
expect(projectQuery).toEqual({
organization: {
id: expect.any(String),
project: null,
slug: org.organization.slug,
},
});
await org.forceExpireTokens([result.createPersonalAccessToken.ok.createdPersonalAccessToken.id]);
const expiredResult = await execute({
document: OrganizationProjectTargetQuery1,
variables: {
organizationSlug: org.organization.slug,
projectSlug: project2.project.slug,
targetSlug: project2.target.slug,
},
authToken: personalAccessToken,
}).then(e => e.expectGraphQLErrors());
const error = expiredResult[0];
expect(error.message).toEqual('Invalid token provided');
});

View file

@ -1,11 +1,12 @@
import { buildASTSchema, parse } from 'graphql';
import { createLogger } from 'graphql-yoga';
import { sql } from 'slonik';
import { pollFor } from 'testkit/flow';
import { initSeed } from 'testkit/seed';
import { getServiceHost } from 'testkit/utils';
import z from 'zod';
import { createHive } from '@graphql-hive/core';
import { clickHouseInsert, clickHouseQuery } from '../../testkit/clickhouse';
import { psql } from '@hive/postgres';
import { clickHouseInsert } from '../../testkit/clickhouse';
import { graphql } from '../../testkit/gql';
import { execute } from '../../testkit/graphql';
@ -957,6 +958,221 @@ test('add documents to app deployment fails if document does not pass validation
});
});
test('app deployment validates documents with MCP directives when schema does not define them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-test',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-test',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc") @mcpHeader(name: "X-Location")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
{
hash: 'mcp-weather-title',
body: [
'query GetWeatherTitle(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather_title", title: "Weather Tool", meta: "{}") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
});
test('app deployment validates documents with MCP directives when schema already defines them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
scalar JSON
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
directive @mcpDescription(provider: String!) on VARIABLE_DEFINITION | FIELD
directive @mcpHeader(name: String!) on VARIABLE_DEFINITION
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment: successResult } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather-existing',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(successResult.error).toBeNull();
const { addDocumentsToAppDeployment: failResult } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather-title',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", title: "Weather Tool") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(failResult.error).toEqual(
expect.objectContaining({
message: expect.stringContaining('not valid'),
details: expect.objectContaining({
message: expect.stringContaining('title'),
}),
}),
);
});
test('app deployment injects only missing MCP directives when schema partially defines them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-partial',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-partial',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-partial-weather',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
});
test('add documents to app deployment fails if document contains multiple executable operation definitions', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
@ -2056,12 +2272,20 @@ test('activeAppDeployments works for > 1000 records with a date filter (neverUse
);
// insert into postgres
const result = await conn.pool.query(sql`
const result = await conn.pool
.any(
psql`
INSERT INTO app_deployments ("target_id", "name", "version", "activated_at")
SELECT * FROM ${sql.unnest(appDeploymentRows, ['uuid', 'text', 'text', 'timestamptz'])}
SELECT * FROM ${psql.unnest(appDeploymentRows, ['uuid', 'text', 'text', 'timestamptz'])}
RETURNING "id", "target_id", "name", "version"
`);
expect(result.rowCount).toBe(1200);
`,
)
.then(
z.array(
z.object({ id: z.string(), target_id: z.string(), name: z.string(), version: z.string() }),
).parse,
);
expect(result.length).toBe(1200);
// insert into clickhouse and activate
const query = `INSERT INTO app_deployments (
@ -2071,7 +2295,7 @@ test('activeAppDeployments works for > 1000 records with a date filter (neverUse
,"app_version"
,"is_active"
) VALUES
${result.rows
${result
.map(
r => `(
'${r['target_id']}'

View file

@ -0,0 +1,38 @@
import { initSeed } from '../../../testkit/seed';
test.concurrent(
'should not allow HOBBY organization to use updateOrgRateLimit mutation',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { updateOrgRateLimit, overrideOrgPlan } = await createOrg();
await expect(updateOrgRateLimit(50000000)).rejects.toThrowError(
'Only PRO organizations can update rate limits via API',
);
},
);
test.concurrent(
'should not allow ENTERPRISE organization to use updateOrgRateLimit mutation',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { updateOrgRateLimit, overrideOrgPlan } = await createOrg();
await overrideOrgPlan('ENTERPRISE');
await expect(updateOrgRateLimit(50000000)).rejects.toThrowError(
'Only PRO organizations can update rate limits via API',
);
},
);
test.concurrent(
'should only allow PRO organization to use updateOrgRateLimit mutation',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { overrideOrgPlan, updateOrgRateLimit } = await createOrg();
await overrideOrgPlan('PRO');
// Not throwing - should succeed
await updateOrgRateLimit(50000000);
},
);

View file

@ -1,3 +1,4 @@
import { pollFor } from 'testkit/flow';
import { graphql } from 'testkit/gql';
import { ResourceAssignmentModeType } from 'testkit/gql/graphql';
import { execute } from 'testkit/graphql';
@ -115,15 +116,19 @@ test.concurrent('cannot delete a role with members', async ({ expect }) => {
test.concurrent('email invitation', async ({ expect }) => {
const seed = initSeed();
const { createOrg } = await seed.createOwner();
const { inviteMember } = await createOrg();
const { inviteMember, organization } = await createOrg();
const inviteEmail = seed.generateEmail();
const invitationResult = await inviteMember(inviteEmail);
const inviteCode = invitationResult.ok?.createdOrganizationInvitation.code;
expect(inviteCode).toBeDefined();
const sentEmails = await history();
expect(sentEmails).toContainEqual(expect.objectContaining({ to: inviteEmail }));
await pollFor(async () => {
const sentEmails = await history(inviteEmail);
return sentEmails.length > 0;
});
const sentEmails = await history(inviteEmail);
expect(sentEmails[0].subject).toEqual('You have been invited to join ' + organization.slug);
});
test.concurrent('can not invite with role not existing in organization', async ({ expect }) => {

View file

@ -14,7 +14,8 @@ function filterEmailsByOrg(orgSlug: string, emails: emails.Email[]) {
test('rate limit approaching and reached for organization', async () => {
const { createOrg, ownerToken, ownerEmail } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const { createProject, organization, overrideOrgPlan } = await createOrg();
await overrideOrgPlan('PRO');
const { createTargetAccessToken, waitForRequestsCollected } = await createProject(
ProjectType.Single,
);

View file

@ -1,21 +1,28 @@
import 'reflect-metadata';
import { sql, type CommonQueryMethods } from 'slonik';
/* eslint-disable no-process-env */
import { ProjectType } from 'testkit/gql/graphql';
import { test } from 'vitest';
import z from 'zod';
import { psql, type CommonQueryMethods } from '@hive/postgres';
import { initSeed } from '../../../testkit/seed';
async function fetchCoordinates(db: CommonQueryMethods, target: { id: string }) {
const result = await db.query<{
coordinate: string;
created_in_version_id: string;
deprecated_in_version_id: string | null;
}>(sql`
const result = await db
.any(
psql`
SELECT coordinate, created_in_version_id, deprecated_in_version_id
FROM schema_coordinate_status WHERE target_id = ${target.id}
`);
`,
)
.then(
z.object({
coordinate: z.string(),
created_in_version_id: z.string(),
deprecated_in_version_id: z.string().nullable(),
}).parse,
);
return result.rows;
return result;
}
describe.skip('schema cleanup tracker', () => {

View file

@ -1,10 +1,11 @@
import 'reflect-metadata';
import { createPool, sql } from 'slonik';
import { graphql } from 'testkit/gql';
/* eslint-disable no-process-env */
import { ProjectType } from 'testkit/gql/graphql';
import { execute } from 'testkit/graphql';
import { assertNonNull, getServiceHost } from 'testkit/utils';
import z from 'zod';
import { createPostgresDatabasePool, psql } from '@hive/postgres';
// eslint-disable-next-line import/no-extraneous-dependencies
import { createStorage } from '@hive/storage';
import { createTarget, publishSchema, updateSchemaComposition } from '../../../testkit/flow';
@ -3820,7 +3821,7 @@ test.concurrent(
);
const insertLegacyVersion = async (
pool: Awaited<ReturnType<typeof createPool>>,
pool: Awaited<ReturnType<typeof createPostgresDatabasePool>>,
args: {
sdl: string;
projectId: string;
@ -3828,7 +3829,9 @@ const insertLegacyVersion = async (
serviceUrl: string;
},
) => {
const logId = await pool.oneFirst<string>(sql`
const logId = await pool
.oneFirst(
psql`
INSERT INTO schema_log
(
author,
@ -3854,9 +3857,13 @@ const insertLegacyVersion = async (
'PUSH'
)
RETURNING id
`);
`,
)
.then(z.string().parse);
const versionId = await pool.oneFirst<string>(sql`
const versionId = await pool
.oneFirst(
psql`
INSERT INTO schema_versions
(
is_composable,
@ -3870,9 +3877,11 @@ const insertLegacyVersion = async (
${logId}
)
RETURNING "id"
`);
`,
)
.then(z.string().parse);
await pool.query(sql`
await pool.query(psql`
INSERT INTO
schema_version_to_log
(version_id, action_id)
@ -3886,7 +3895,7 @@ const insertLegacyVersion = async (
test.concurrent(
'service url change from legacy to new version is displayed correctly',
async ({ expect }) => {
let pool: Awaited<ReturnType<typeof createPool>> | undefined;
let pool: Awaited<ReturnType<typeof createPostgresDatabasePool>> | undefined;
try {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
@ -3899,7 +3908,9 @@ test.concurrent(
// We need to seed a legacy entry in the database
const conn = connectionString();
pool = await createPool(conn);
pool = await createPostgresDatabasePool({
connectionParameters: conn,
});
const sdl = 'type Query { ping: String! }';
@ -3950,7 +3961,7 @@ test.concurrent(
test.concurrent(
'service url change from legacy to legacy version is displayed correctly',
async ({ expect }) => {
let pool: Awaited<ReturnType<typeof createPool>> | undefined;
let pool: Awaited<ReturnType<typeof createPostgresDatabasePool>> | undefined;
try {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
@ -3963,7 +3974,7 @@ test.concurrent(
// We need to seed a legacy entry in the database
const conn = connectionString();
pool = await createPool(conn);
pool = await createPostgresDatabasePool({ connectionParameters: conn });
const sdl = 'type Query { ping: String! }';

View file

@ -1,5 +1,7 @@
import { pollFor, readTokenInfo } from 'testkit/flow';
import { ProjectType } from 'testkit/gql/graphql';
import { createTokenStorage } from '@hive/storage';
import { generateToken } from '@hive/tokens';
import { initSeed } from '../../testkit/seed';
test.concurrent('deleting a token should clear the cache', async () => {
@ -144,3 +146,59 @@ test.concurrent(
`);
},
);
test.concurrent(
'regression: reading existing token with "last_used_at" from pg database (and not redis cache) does not raise an exception',
async ({ expect }) => {
const seed = initSeed();
const { createOrg } = await seed.createOwner();
const { createProject, organization } = await createOrg();
const { project, target } = await createProject();
const tokenStorage = await createTokenStorage(seed.getPGConnectionString(), 1);
try {
const token = generateToken();
// create new token so it does not yet exist in redis cache
const record = await tokenStorage.createToken({
name: 'foo',
organization: organization.id,
project: project.id,
target: target.id,
scopes: [],
token: token.hash,
tokenAlias: token.alias,
});
// touch the token so it has a date
await tokenStorage.touchTokens({ tokens: [{ token: record.token, date: new Date() }] });
const result = await readTokenInfo(token.secret).then(res => res.expectNoGraphQLErrors());
expect(result.tokenInfo).toMatchInlineSnapshot(`
{
__typename: TokenInfo,
hasOrganizationDelete: false,
hasOrganizationIntegrations: false,
hasOrganizationMembers: false,
hasOrganizationRead: false,
hasOrganizationSettings: false,
hasProjectAlerts: false,
hasProjectDelete: false,
hasProjectOperationsStoreRead: false,
hasProjectOperationsStoreWrite: false,
hasProjectRead: false,
hasProjectSettings: false,
hasTargetDelete: false,
hasTargetRead: false,
hasTargetRegistryRead: false,
hasTargetRegistryWrite: false,
hasTargetSettings: false,
hasTargetTokensRead: false,
hasTargetTokensWrite: false,
}
`);
} finally {
await tokenStorage.destroy();
}
},
);

View file

@ -1,160 +0,0 @@
import { existsSync, rmSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MaybePromise } from 'slonik/dist/src/types';
import { ProjectType } from 'testkit/gql/graphql';
import { initSeed } from 'testkit/seed';
import { getServiceHost } from 'testkit/utils';
import { execa } from '@esm2cjs/execa';
describe('Apollo Router Integration', () => {
const getAvailablePort = () =>
new Promise<number>(resolve => {
const server = createServer();
server.listen(0, () => {
const address = server.address();
if (address && typeof address === 'object') {
const port = address.port;
server.close(() => resolve(port));
} else {
throw new Error('Could not get available port');
}
});
});
function defer(deferFn: () => MaybePromise<void>) {
return {
async [Symbol.asyncDispose]() {
return deferFn();
},
};
}
it('fetches the supergraph and sends usage reports', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createTargetAccessToken, createCdnAccess, target, waitForOperationsCollected } =
await createProject(ProjectType.Federation);
const writeToken = await createTargetAccessToken({});
// Publish Schema
const publishSchemaResult = await writeToken
.publishSchema({
author: 'Arda',
commit: 'abc123',
sdl: /* GraphQL */ `
type Query {
me: User
}
type User {
id: ID!
name: String!
}
`,
service: 'users',
url: 'https://federation-demo.theguild.workers.dev/users',
})
.then(r => r.expectNoGraphQLErrors());
expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
const cdnAccessResult = await createCdnAccess();
const usageAddress = await getServiceHost('usage', 8081);
const routerBinPath = join(__dirname, '../../../target/debug/router');
if (!existsSync(routerBinPath)) {
throw new Error(
`Apollo Router binary not found at path: ${routerBinPath}, make sure to build it first with 'cargo build'`,
);
}
const routerPort = await getAvailablePort();
const routerConfigContent = `
supergraph:
listen: 0.0.0.0:${routerPort}
plugins:
hive.usage: {}
`.trim();
const routerConfigPath = join(tmpdir(), `apollo-router-config-${Date.now()}.yaml`);
writeFileSync(routerConfigPath, routerConfigContent, 'utf-8');
const cdnEndpoint = await getServiceHost('server', 8082).then(
v => `http://${v}/artifacts/v1/${target.id}`,
);
const routerProc = execa(routerBinPath, ['--dev', '--config', routerConfigPath], {
all: true,
env: {
HIVE_CDN_ENDPOINT: cdnEndpoint,
HIVE_CDN_KEY: cdnAccessResult.secretAccessToken,
HIVE_ENDPOINT: `http://${usageAddress}`,
HIVE_TOKEN: writeToken.secret,
},
});
let log = '';
await new Promise((resolve, reject) => {
routerProc.catch(err => {
if (!err.isCanceled) {
reject(err);
}
});
const routerProcOut = routerProc.all;
if (!routerProcOut) {
return reject(new Error('No stdout from Apollo Router process'));
}
routerProcOut.on('data', data => {
log += data.toString();
if (log.includes('GraphQL endpoint exposed at')) {
resolve(true);
}
process.stdout.write(log);
});
});
await using _ = defer(() => {
rmSync(routerConfigPath);
routerProc.cancel();
});
const url = `http://localhost:${routerPort}/`;
async function sendOperation(i: number) {
const response = await fetch(url, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
query: `
query Query${i} {
me {
id
name
}
}
`,
}),
});
expect(response.status).toBe(200);
const result = await response.json();
expect(result).toEqual({
data: {
me: {
id: '1',
name: 'Ada Lovelace',
},
},
});
}
const cnt = 1000;
const jobs = [];
for (let i = 0; i < cnt; i++) {
if (i % 100 === 0) {
await new Promise(res => setTimeout(res, 500));
}
jobs.push(sendOperation(i));
}
await Promise.all(jobs);
await waitForOperationsCollected(cnt);
});
});

View file

@ -74,7 +74,6 @@ test('update-retention script skips gracefully when no env vars are set', async
delete process.env.CLICKHOUSE_TTL_HOURLY_MV_TABLES;
delete process.env.CLICKHOUSE_TTL_MINUTELY_MV_TABLES;
vi.resetModules();
const { updateRetention } = await import(
'../../../packages/migrations/src/scripts/update-retention'
);

View file

@ -37,8 +37,6 @@ export default defineConfig({
},
setupFiles,
testTimeout: 90_000,
exclude: process.env.TEST_APOLLO_ROUTER
? defaultExclude
: [...defaultExclude, 'tests/apollo-router/**'],
exclude: defaultExclude,
},
});

View file

@ -14,7 +14,7 @@
"private": true,
"packageManager": "pnpm@10.18.3+sha512.bbd16e6d7286fd7e01f6b3c0b3c932cda2965c06a908328f74663f10a9aea51f1129eea615134bf992831b009eabe167ecb7008b597f40ff9bc75946aadfb08d",
"engines": {
"node": ">=24.13",
"node": ">=24.14.1",
"pnpm": ">=10.16.0"
},
"scripts": {
@ -42,7 +42,7 @@
"prerelease": "pnpm build:libraries",
"prettier": "prettier --cache --write --list-different --ignore-unknown \"**/*\"",
"release": "pnpm build:libraries && changeset publish",
"release:version": "changeset version && pnpm --filter hive-apollo-router-plugin sync-cargo-file && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme",
"release:version": "changeset version && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme",
"seed:app-deployments": "tsx scripts/seed-app-deployments.mts",
"seed:insights": "tsx scripts/seed-insights.mts",
"seed:org": "tsx scripts/seed-organization.mts",
@ -54,7 +54,6 @@
"test:e2e:local": "CYPRESS_BASE_URL=http://localhost:3000 RUN_AGAINST_LOCAL_SERVICES=1 cypress open --browser chrome",
"test:e2e:open": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress open",
"test:integration": "cd integration-tests && pnpm test:integration",
"test:integration:apollo-router": "cd integration-tests && pnpm test:integration:apollo-router",
"typecheck": "pnpm run -r --filter '!hive' typecheck",
"upload-sourcemaps": "./scripts/upload-sourcemaps.sh",
"workspace": "pnpm run --filter $1 $2"
@ -72,7 +71,7 @@
"@graphql-codegen/typescript-operations": "5.0.2",
"@graphql-codegen/typescript-resolvers": "5.1.0",
"@graphql-codegen/urql-introspection": "3.0.1",
"@graphql-eslint/eslint-plugin": "4.4.0",
"@graphql-eslint/eslint-plugin": "3.20.1",
"@graphql-inspector/cli": "6.0.6",
"@graphql-inspector/core": "7.1.2",
"@graphql-inspector/patch": "0.1.3",
@ -82,10 +81,10 @@
"@parcel/watcher": "2.5.1",
"@sentry/cli": "2.40.0",
"@swc/core": "1.13.5",
"@theguild/eslint-config": "0.13.1",
"@theguild/federation-composition": "0.22.1",
"@theguild/eslint-config": "0.12.1",
"@theguild/federation-composition": "0.22.2",
"@theguild/prettier-config": "2.0.7",
"@types/node": "24.10.9",
"@types/node": "24.12.2",
"bob-the-bundler": "7.0.1",
"cypress": "13.17.0",
"dotenv": "16.4.7",
@ -109,7 +108,7 @@
"turbo": "2.5.8",
"typescript": "5.7.3",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.9"
"vitest": "4.1.3"
},
"pnpm": {
"overrides.esbuild": "To address CVE: https://github.com/graphql-hive/console/security/dependabot/259",
@ -126,9 +125,9 @@
"overrides.fast-xml-parser@5.x.x": "address https://github.com/graphql-hive/console/security/dependabot/576",
"overrides.minimatch@10.x.x": "address https://github.com/graphql-hive/console/security/dependabot/505",
"overrides.qs@<6.14.2": "address https://github.com/graphql-hive/console/security/dependabot/499",
"overrides.dompurify@3.x.x": "address https://github.com/graphql-hive/console/security/dependabot/536",
"overrides.ajv@8.x.x": "address https://github.com/graphql-hive/console/security/dependabot/507",
"overrides.yauzl@2.x.x": "address https://github.com/graphql-hive/console/security/dependabot/542",
"overrides.path-to-regexp@0.x.x": "address https://github.com/graphql-hive/console/security/dependabot/619",
"overrides": {
"esbuild": "0.25.9",
"csstype": "3.1.2",
@ -161,18 +160,18 @@
"minimatch@3.x.x": "^3.1.3",
"minimatch@4.x.x": "^4.2.4",
"qs@<6.14.2": "^6.14.2",
"dompurify@3.x.x": "^3.3.2",
"ajv@8.x.x": "^8.18.0",
"yauzl@2.x.x": "^3.2.1",
"glob@10.x.x": "^10.5.0"
"glob@10.x.x": "^10.5.0",
"path-to-regexp@0.x.x": "^0.1.13"
},
"patchedDependencies": {
"mjml-core@4.14.0": "patches/mjml-core@4.14.0.patch",
"@apollo/federation@0.38.1": "patches/@apollo__federation@0.38.1.patch",
"@theguild/editor@1.2.5": "patches/@theguild__editor@1.2.5.patch",
"eslint@8.57.1": "patches/eslint@8.57.1.patch",
"@graphql-eslint/eslint-plugin@3.20.1": "patches/@graphql-eslint__eslint-plugin@3.20.1.patch",
"got@14.4.7": "patches/got@14.4.7.patch",
"slonik@30.4.4": "patches/slonik@30.4.4.patch",
"@oclif/core@3.26.6": "patches/@oclif__core@3.26.6.patch",
"oclif": "patches/oclif.patch",
"graphiql": "patches/graphiql.patch",
@ -181,7 +180,9 @@
"p-cancelable@4.0.1": "patches/p-cancelable@4.0.1.patch",
"bentocache": "patches/bentocache.patch",
"@graphql-codegen/schema-ast": "patches/@graphql-codegen__schema-ast.patch",
"@fastify/vite": "patches/@fastify__vite.patch"
"@fastify/vite": "patches/@fastify__vite.patch",
"@slonik/pg-driver": "patches/@slonik__pg-driver.patch",
"slonik": "patches/slonik.patch"
},
"onlyBuiltDependencies": [
"msw"

View file

@ -0,0 +1,3 @@
# Internal Postgres Client
This is a lightweight abstraction on top of Slonik, that sets up some things for ease of usage.

View file

@ -0,0 +1,19 @@
{
"name": "@hive/postgres",
"type": "module",
"license": "MIT",
"private": true,
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@standard-schema/spec": "1.0.0",
"slonik": "48.13.2",
"slonik-interceptor-query-logging": "48.13.2",
"slonik-sql-tag-raw": "48.13.2",
"zod": "3.25.76"
},
"devDependencies": {
"@hive/service-common": "workspace:*"
}
}

View file

@ -1,11 +1,14 @@
export function createConnectionString(config: {
export type PostgresConnectionParamaters = {
host: string;
port: number;
password: string | undefined;
user: string;
db: string;
ssl: boolean;
}) {
};
/** Create a Postgres Connection String */
export function createConnectionString(config: PostgresConnectionParamaters) {
// prettier-ignore
const encodedUser = encodeURIComponent(config.user);
const encodedPassword =

View file

@ -0,0 +1,17 @@
export {
PostgresDatabasePool,
createPostgresDatabasePool,
type CommonQueryMethods,
} from './postgres-database-pool';
export { type PostgresConnectionParamaters, createConnectionString } from './connection-string';
export { psql, type TaggedTemplateLiteralInvocation } from './psql';
export {
UniqueIntegrityConstraintViolationError,
ForeignKeyIntegrityConstraintViolationError,
type PrimitiveValueExpression,
type SerializableValue,
type Interceptor,
type Query,
type QueryContext,
} from 'slonik';
export { toDate } from './utils';

View file

@ -0,0 +1,91 @@
import type { PoolClient } from 'pg';
import { sql, type DatabasePool, type DatabasePoolConnection } from 'slonik';
import { raw } from 'slonik-sql-tag-raw';
/**
* Bridge {slonik.DatabasePool} to an {pg.Pool} for usage with Postgraphile Workers.
*
* This is a very very pragmatic approach, since slonik moved away from using {pg.Pool} internally.
* https://github.com/gajus/slonik/issues/768
**/
export class PgPoolBridge {
constructor(private pool: DatabasePool) {}
end(): never {
throw new Error('Not implemented.');
}
async connect(): Promise<PoolClient> {
const pgClientAvailableP = Promise.withResolvers<any>();
const pgClientReleasedP = Promise.withResolvers<void>();
// slonik connect works in a way where the client is acquired for the callback handler closure only.
// It is released once the Promise returned from the handler resolves.
// We need to be a bit creative to support the "pg.Pool" API and obviously
// trust graphile-workers to call the `release` method on our fake {pg.Client} :)
// so the client is released back to the pool
void this.pool.connect(async client => {
pgClientAvailableP.resolve(new PgClientBridge(client, pgClientReleasedP.resolve));
await pgClientReleasedP.promise;
});
return pgClientAvailableP.promise;
}
/** Some of graphile-workers logic just calls the `query` method on {pg.Pool} - without first acquiring a connection. */
query(query: unknown, values?: unknown, callback?: unknown): any {
// not used, but just in case so we can catch it in the future...
if (typeof callback !== 'undefined') {
throw new Error('PgClientBridge.query: callback not supported');
}
if ((typeof query !== 'string' && typeof query !== 'object') || !query) {
throw new Error('PgClientBridge.query: unsupported query input');
}
if (typeof query === 'string') {
return this.pool.query(sql.unsafe`${raw(query, values as any)}`);
}
return this.pool.query(sql.unsafe`${raw((query as any).text as any, (query as any).values)}`);
}
on(): this {
// Note: we can skip setting up event handlers, as graphile workers is only setting up error handlers to avoid uncaught exceptions
// For us, the error handlers are already set up by slonik
// https://github.com/graphile/worker/blob/5650fbc4406fa3ce197b2ab582e08fd20974e50c/src/lib.ts#L351-L359
return this;
}
removeListener(): this {
// Note: we can skip tearing down event handlers, since we ship setting them up in the first place
// https://github.com/graphile/worker/blob/5650fbc4406fa3ce197b2ab582e08fd20974e50c/src/lib.ts#L351-L359
return this;
}
}
class PgClientBridge {
constructor(
private connection: DatabasePoolConnection,
/** This is invoked for again releasing the connection. */
public release: () => void,
) {}
query(query: unknown, values?: unknown, callback?: unknown): any {
if (typeof callback !== 'undefined') {
throw new Error('PgClientBridge.query: callback not supported');
}
if ((typeof query !== 'string' && typeof query !== 'object') || !query) {
throw new Error('PgClientBridge.query: unsupported query input');
}
if (typeof query === 'string') {
return this.connection.query(sql.unsafe`${raw(query, values as any)}`);
}
return this.connection.query(
sql.unsafe`${raw((query as any).text as any, (query as any).values)}`,
);
}
}

View file

@ -0,0 +1,223 @@
import type { Pool } from 'pg';
import {
createPool,
createTypeParserPreset,
type DatabasePool,
type Interceptor,
type PrimitiveValueExpression,
type QuerySqlToken,
type CommonQueryMethods as SlonikCommonQueryMethods,
} from 'slonik';
import { createQueryLoggingInterceptor } from 'slonik-interceptor-query-logging';
import { context, SpanKind, SpanStatusCode, trace } from '@hive/service-common';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { createConnectionString, type PostgresConnectionParamaters } from './connection-string';
import { PgPoolBridge } from './pg-pool-bridge';
const tracer = trace.getTracer('storage');
export interface CommonQueryMethods {
exists<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<boolean>;
any<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>>>;
maybeOne<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<null | StandardSchemaV1.InferOutput<T>>;
query(sql: QuerySqlToken<any>, values?: PrimitiveValueExpression[]): Promise<void>;
oneFirst<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>;
one<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<StandardSchemaV1.InferOutput<T>>;
anyFirst<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>>;
maybeOneFirst<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<null | StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>;
}
export class PostgresDatabasePool implements CommonQueryMethods {
constructor(private pool: DatabasePool) {}
getPgPoolCompat(): Pool {
return new PgPoolBridge(this.pool) as any;
}
/** Retrieve the raw Slonik instance. Refrain from using this API. */
getSlonikPool() {
return this.pool;
}
async exists<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<boolean> {
return this.pool.exists(sql, values);
}
async any<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>>> {
return this.pool.any(sql, values);
}
async maybeOne<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<null | StandardSchemaV1.InferOutput<T>> {
return this.pool.maybeOne(sql, values);
}
async query(sql: QuerySqlToken<any>, values?: PrimitiveValueExpression[]): Promise<void> {
await this.pool.query(sql, values);
}
async oneFirst<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]> {
return await this.pool.oneFirst(sql, values);
}
async maybeOneFirst<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<null | StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]> {
return await this.pool.maybeOneFirst(sql, values);
}
async one<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<StandardSchemaV1.InferOutput<T>> {
return await this.pool.one(sql, values);
}
async anyFirst<T extends StandardSchemaV1>(
sql: QuerySqlToken<T>,
values?: PrimitiveValueExpression[],
): Promise<
ReadonlyArray<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>
> {
return await this.pool.anyFirst(sql, values);
}
async transaction<T = void>(
name: string,
handler: (methods: CommonQueryMethods) => Promise<T>,
): Promise<T> {
const span = tracer.startSpan(`PG Transaction: ${name}`, {
kind: SpanKind.INTERNAL,
});
return context.with(trace.setSpan(context.active(), span), async () => {
return await this.pool.transaction(async methods => {
try {
return await handler({
exists: methods.exists,
any: methods.any,
maybeOne: methods.maybeOne,
async query(
sql: QuerySqlToken<any>,
values?: PrimitiveValueExpression[],
): Promise<void> {
await methods.query(sql, values);
},
oneFirst: methods.oneFirst,
maybeOneFirst: methods.maybeOneFirst,
anyFirst: methods.anyFirst,
one: methods.one,
});
} catch (err) {
span.setAttribute('error', 'true');
if (err instanceof Error) {
span.setAttribute('error.type', err.name);
span.setAttribute('error.message', err.message);
span.setStatus({
code: SpanStatusCode.ERROR,
message: err.message,
});
}
throw err;
} finally {
span.end();
}
});
});
}
end(): Promise<void> {
return this.pool.end();
}
}
const dbInterceptors: Interceptor[] = [createQueryLoggingInterceptor()];
const typeParsers = [
...createTypeParserPreset().filter(parser => parser.name !== 'int8'),
{
name: 'int8',
parse: (value: string) => parseInt(value, 10),
},
];
export async function createPostgresDatabasePool(args: {
connectionParameters: PostgresConnectionParamaters | string;
maximumPoolSize?: number;
additionalInterceptors?: Interceptor[];
statementTimeout?: number;
}) {
const connectionString =
typeof args.connectionParameters === 'string'
? args.connectionParameters
: createConnectionString(args.connectionParameters);
const pool = await createPool(connectionString, {
interceptors: dbInterceptors.concat(args.additionalInterceptors ?? []),
typeParsers,
captureStackTrace: false,
maximumPoolSize: args.maximumPoolSize,
idleTimeout: 30000,
statementTimeout: args.statementTimeout,
});
function interceptError<K extends Exclude<keyof SlonikCommonQueryMethods, 'transaction'>>(
methodName: K,
) {
const original: SlonikCommonQueryMethods[K] = pool[methodName];
function interceptor(
this: any,
sql: QuerySqlToken<any>,
values?: PrimitiveValueExpression[],
): any {
return (original as any).call(this, sql, values).catch((error: any) => {
error.sql = sql.sql;
error.values = sql.values || values;
return Promise.reject(error);
});
}
pool[methodName] = interceptor as any;
}
interceptError('one');
interceptError('many');
return new PostgresDatabasePool(pool);
}

View file

@ -0,0 +1,23 @@
import { createSqlTag, QuerySqlToken, type SqlTag, type ValueExpression } from 'slonik';
import { z } from 'zod';
import { StandardSchemaV1 } from '@standard-schema/spec';
const tag = createSqlTag();
interface TemplateStringsArray extends ReadonlyArray<string> {
readonly raw: readonly string[];
}
type CallableTag<
T extends StandardSchemaV1<unknown, unknown> = StandardSchemaV1<unknown, unknown>,
> = (template: TemplateStringsArray, ...values: ValueExpression[]) => QuerySqlToken<T>;
export type TaggedTemplateLiteralInvocation = QuerySqlToken<any>;
function psqlFn(template: TemplateStringsArray, ...values: ValueExpression[]) {
return tag.type(z.unknown())(template, ...values);
}
Object.assign(psqlFn, createSqlTag());
export const psql = psqlFn as any as SqlTag<any> & CallableTag;

View file

@ -0,0 +1,5 @@
import { psql } from './psql';
export function toDate(date: Date) {
return psql`to_timestamp(${date.getTime() / 1000})`;
}

View file

@ -51,7 +51,7 @@
"@graphql-hive/logger": "^1.0.9"
},
"devDependencies": {
"@apollo/server": "5.4.0",
"@apollo/server": "5.5.0",
"@as-integrations/express4": "1.1.2",
"@graphql-tools/schema": "10.0.25",
"@types/express": "4.17.21",
@ -60,7 +60,7 @@
"graphql": "16.9.0",
"graphql-ws": "5.16.1",
"nock": "14.0.10",
"vitest": "4.0.9",
"vitest": "4.1.3",
"ws": "8.18.0"
},
"publishConfig": {

View file

@ -60,7 +60,7 @@
"@oclif/core": "3.26.6",
"@oclif/plugin-help": "6.2.36",
"@oclif/plugin-update": "4.7.16",
"@theguild/federation-composition": "0.22.1",
"@theguild/federation-composition": "0.22.2",
"cli-table3": "0.6.5",
"colors": "1.4.0",
"env-ci": "7.3.0",

View file

@ -121,7 +121,7 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm
ensure<
TKey extends ValidConfigurationKeys,
TArgs extends {
[key in TKey]: GetConfigurationValueType<TKey>;
[_key in TKey]: GetConfigurationValueType<TKey>;
},
>({
key,

View file

@ -65,7 +65,7 @@
"graphql": "16.9.0",
"nock": "14.0.10",
"tslib": "2.8.1",
"vitest": "4.0.9"
"vitest": "4.1.3"
},
"publishConfig": {
"registry": "https://registry.npmjs.org",

View file

@ -202,7 +202,7 @@ type OptionalWhenFalse<T, KCond extends keyof T, KExcluded extends keyof T> =
// untouched by default or when true
| T
// when false, make KExcluded optional
| (Omit<T, KExcluded> & { [P in KCond]: false } & { [P in KExcluded]?: T[KExcluded] });
| (Omit<T, KExcluded> & { [_P in KCond]: false } & { [_P in KExcluded]?: T[KExcluded] });
export type HivePluginOptions = OptionalWhenFalse<
{

View file

@ -139,7 +139,7 @@ export function addProperty<T, K extends string, V>(
value: V,
obj: T,
): T & {
[k in K]: V;
[_k in K]: V;
};
export function addProperty<T, K extends string, V>(
key: K,

View file

@ -61,9 +61,9 @@
},
"devDependencies": {
"@apollo/composition": "2.13.2",
"@types/node": "24.10.9",
"@types/node": "24.12.2",
"esbuild": "0.25.9",
"fastify": "5.8.1",
"fastify": "5.8.5",
"graphql": "16.9.0"
},
"publishConfig": {

View file

@ -1,5 +1,39 @@
# @graphql-hive/laboratory
## 0.1.5
### Patch Changes
- [#7989](https://github.com/graphql-hive/console/pull/7989)
[`863f920`](https://github.com/graphql-hive/console/commit/863f920b86505a3d84c9001fef1c3e8a723bdca9)
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Enhanced behavior when no collection
exists and the user attempts to save an operation, along with the ability to edit the collection
name.
## 0.1.4
### Patch Changes
- [#7963](https://github.com/graphql-hive/console/pull/7963)
[`4a8bd4f`](https://github.com/graphql-hive/console/commit/4a8bd4fd1b4fbb34076e97d06ed1341432de451d)
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Implemented functionality that allows
to have multiple queries in same operation while working only with focused one (run button, query
builder)
- [#7892](https://github.com/graphql-hive/console/pull/7892)
[`fab4b03`](https://github.com/graphql-hive/console/commit/fab4b03ace2ff20759bbcd33465d00a5cbbc4c97)
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Hive Laboratory renders Hive Router
query plan if included in response extensions
## 0.1.3
### Patch Changes
- [#7888](https://github.com/graphql-hive/console/pull/7888)
[`574a5d8`](https://github.com/graphql-hive/console/commit/574a5d823e71ca1d0628897a73e2fab1d0d5bfe0)
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - If schema introspection isn't provided
as property to Laboratory, lab will start interval to fetch schema every second.
## 0.1.2
### Patch Changes

View file

@ -12,11 +12,11 @@
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/laboratory/components",
"utils": "@/laboratory/lib/utils",
"ui": "@/laboratory/components/ui",
"lib": "@/laboratory/lib",
"hooks": "@/laboratory/hooks"
"components": "src/components",
"utils": "src/lib/utils",
"ui": "src/components/ui",
"lib": "src/lib",
"hooks": "src/hooks"
},
"registries": {}
}

View file

@ -1,6 +1,6 @@
{
"name": "@graphql-hive/laboratory",
"version": "0.1.2",
"version": "0.1.5",
"type": "module",
"license": "MIT",
"main": "./dist/hive-laboratory.cjs.js",
@ -29,20 +29,23 @@
"peerDependencies": {
"@tanstack/react-form": "^1.23.8",
"date-fns": "^4.1.0",
"graphql-ws": "^6.0.6",
"lucide-react": "^0.548.0",
"lz-string": "^1.5.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"subscriptions-transport-ws": "^0.11.0",
"tslib": "^2.8.1",
"zod": "^4.1.12"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"@graphql-tools/url-loader": "^9.1.0",
"radix-ui": "^1.4.3",
"react-zoom-pan-pinch": "^3.7.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@dagrejs/dagre": "^1.1.8",
"@dagrejs/dagre": "^2.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -76,7 +79,7 @@
"@tanstack/router-plugin": "^1.154.13",
"@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.17.23",
"@types/node": "24.10.9",
"@types/node": "24.12.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "^5.1.2",
@ -96,8 +99,7 @@
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"graphql": "^16.12.0",
"graphql-ws": "^6.0.6",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"lucide-react": "^0.548.0",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.2",
@ -112,6 +114,7 @@
"react-shadow": "^20.6.0",
"rollup-plugin-typescript2": "^0.36.0",
"sonner": "^2.0.7",
"subscriptions-transport-ws": "^0.11.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-scoped-preflight": "^3.5.7",

View file

@ -0,0 +1,519 @@
import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { LucideProps, MaximizeIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Slider } from '@/components/ui/slider';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import dagre from '@dagrejs/dagre';
export interface FlowNode {
id: string;
title: string;
next?: string[];
icon?: (props: LucideProps) => React.ReactNode;
content?: (props: { node: FlowNode }) => React.ReactNode;
headerSuffix?: (props: { node: FlowNode }) => React.ReactNode;
children?: FlowNode[];
maxWidth?: number;
}
export interface FlowGraphInternal extends FlowNode {
x: number;
y: number;
width: number;
height: number;
}
export type Point = {
x: number;
y: number;
};
export function orthogonalPoints(from: Point, to: Point, t = 0.5): [Point, Point, Point, Point] {
const midX = from.x + (to.x - from.x) * t;
return [from, { x: midX, y: from.y }, { x: midX, y: to.y }, to];
}
export function roundedOrthogonalPath(
[p0, p1, p2, p3]: [Point, Point, Point, Point],
radius = 12,
): string {
const r1 = Math.min(radius, Math.abs(p1.x - p0.x), Math.abs(p2.y - p1.y));
const r2 = Math.min(radius, Math.abs(p2.y - p1.y), Math.abs(p3.x - p2.x));
const p1a = {
x: p1.x - Math.sign(p1.x - p0.x) * r1,
y: p1.y,
};
const p1b = {
x: p1.x,
y: p1.y + Math.sign(p2.y - p1.y) * r1,
};
const p2a = {
x: p2.x,
y: p2.y - Math.sign(p2.y - p1.y) * r2,
};
const p2b = {
x: p2.x + Math.sign(p3.x - p2.x) * r2,
y: p2.y,
};
return [
`M ${p0.x} ${p0.y}`,
`L ${p1a.x} ${p1a.y}`,
`Q ${p1.x} ${p1.y} ${p1b.x} ${p1b.y}`,
`L ${p2a.x} ${p2a.y}`,
`Q ${p2.x} ${p2.y} ${p2b.x} ${p2b.y}`,
`L ${p3.x} ${p3.y}`,
].join(' ');
}
const MIN_SCALE = 0.2;
const MAX_SCALE = 3;
const ZOOM_STEP = 0.02;
export const Flow = (props: {
nodes: FlowNode[];
margin?: number;
gapX?: number;
gapY?: number;
onGraphLayout?: (graph: dagre.graphlib.Graph) => void;
disableBackground?: boolean;
disableGestures?: boolean;
className?: string;
containerClassName?: string;
isChild?: boolean;
}) => {
const [isCanvasActive, setIsCanvasActive] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const panStartRef = useRef<Point | null>(null);
const [view, setView] = useState<{ x: number; y: number; scale: number }>({
x: 0,
y: 0,
scale: 1,
});
const [nodeSizes, setNodeSizes] = useState<Record<string, { width: number; height: number }>>({});
const [nodes, edges, graphSize] = useMemo(() => {
if (Object.keys(nodeSizes).length === 0) {
return [
props.nodes.map(node => ({ ...node, x: 0, y: 0, width: 0, height: 0, isCluster: false })),
[],
{ width: 0, height: 0 },
];
}
const result = new dagre.graphlib.Graph({
compound: true,
})
.setGraph({
rankdir: 'LR',
align: 'UL',
ranksep: props.gapX ?? 48,
nodesep: props.gapY ?? 48,
marginx: props.margin ?? 32,
marginy: props.margin ?? 64,
graph: 'tight-tree',
})
.setDefaultEdgeLabel(() => ({}));
for (const node of props.nodes) {
result.setNode(node.id, {
width: nodeSizes[node.id]?.width,
height: nodeSizes[node.id]?.height,
});
}
for (const node of props.nodes) {
if (node.next) {
for (const next of node.next) {
result.setEdge(node.id, next);
}
}
}
dagre.layout(result);
props.onGraphLayout?.(result);
const graph = result.graph();
return [
props.nodes.map(node => {
const graphNode = result.node(node.id);
return {
...node,
x: graphNode?.x ?? 0,
y: graphNode?.y ?? 0,
width: graphNode?.width ?? 0,
height: graphNode?.height ?? 0,
};
}),
result.edges().map(edge => {
return {
from: edge.v,
to: edge.w,
};
}),
{ width: graph.width, height: graph.height },
];
}, [nodeSizes, props.nodes, props.margin, props.gapX]);
const handleWheel = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
if (props.disableGestures) {
return;
}
if (event.ctrlKey || event.metaKey) {
const bounds = event.currentTarget.getBoundingClientRect();
const pointerX = event.clientX - bounds.left;
const pointerY = event.clientY - bounds.top;
setView(prev => {
const zoomFactor = Math.exp(-event.deltaY * ZOOM_STEP);
const scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * zoomFactor));
const ratio = scale / prev.scale;
const x = pointerX - (pointerX - prev.x) * ratio;
const y = pointerY - (pointerY - prev.y) * ratio;
return { x, y, scale };
});
return;
}
},
[props.disableGestures],
);
const stopPanning = useCallback(() => {
setIsPanning(false);
panStartRef.current = null;
}, []);
const handleMouseDown = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) {
return;
}
if (!event.nativeEvent.composedPath().includes(containerRef.current)) {
return;
}
if (props.disableGestures) {
return;
}
event.preventDefault();
setIsPanning(true);
panStartRef.current = { x: event.clientX, y: event.clientY };
function handleMouseUp() {
stopPanning();
setIsCanvasActive(false);
}
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
};
},
[props.disableGestures],
);
const handleMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (props.disableGestures || !isPanning || !panStartRef.current) {
return;
}
const deltaX = event.clientX - panStartRef.current.x;
const deltaY = event.clientY - panStartRef.current.y;
panStartRef.current = { x: event.clientX, y: event.clientY };
setView(prev => ({
...prev,
x: prev.x + deltaX,
y: prev.y + deltaY,
}));
},
[isPanning, props.disableGestures],
);
const fitInView = useCallback(() => {
const { width, height } = graphSize;
const container = containerRef.current;
if (!container) {
return;
}
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect();
console.log({
container,
containerWidth,
containerHeight,
width,
height,
});
const scale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, Math.min(containerWidth / width, containerHeight / height)),
);
setView(prev => ({
...prev,
scale,
x: containerWidth / 2 - (width * scale) / 2,
y: containerHeight / 2 - (height * scale) / 2,
}));
}, [graphSize]);
useEffect(() => {
if (props.disableGestures || !containerRef.current) {
return;
}
const element = containerRef.current;
const preventNativeGesture = (event: Event) => {
event.preventDefault();
};
element.addEventListener('gesturestart', preventNativeGesture, { passive: false });
element.addEventListener('gesturechange', preventNativeGesture, { passive: false });
element.addEventListener('gestureend', preventNativeGesture, { passive: false });
element.addEventListener('wheel', preventNativeGesture, { passive: false });
return () => {
element.removeEventListener('gesturestart', preventNativeGesture);
element.removeEventListener('gesturechange', preventNativeGesture);
element.removeEventListener('gestureend', preventNativeGesture);
element.removeEventListener('wheel', preventNativeGesture);
};
}, [props.disableGestures]);
useEffect(() => {
if (props.disableGestures) {
return;
}
const preventBrowserZoomHotkeys = (event: KeyboardEvent) => {
if (!isCanvasActive || (!event.metaKey && !event.ctrlKey)) {
return;
}
if (['+', '-', '=', '0'].includes(event.key)) {
event.preventDefault();
}
};
window.addEventListener('keydown', preventBrowserZoomHotkeys);
return () => {
window.removeEventListener('keydown', preventBrowserZoomHotkeys);
};
}, [isCanvasActive, props.disableGestures]);
return (
<div
ref={containerRef}
className={cn(
'bg-background relative h-full w-full touch-none',
{
'cursor-grab': !props.disableGestures && !isPanning,
'cursor-grabbing': !props.disableGestures && isPanning,
},
props.containerClassName,
)}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={() => {
stopPanning();
setIsCanvasActive(false);
}}
onMouseEnter={() => setIsCanvasActive(true)}
>
{!props.disableBackground && (
<div className="bg-size-[16px_16px] absolute inset-0 h-full w-full bg-[radial-gradient(hsl(var(--border))_1px,transparent_1px)] opacity-50" />
)}
<div
className={cn('relative', props.className)}
style={{
width: graphSize.width,
height: graphSize.height,
transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
transformOrigin: '0 0',
}}
>
{nodes.map(node => {
const hasFollowers = !!node.next?.length;
const hasPrevious = nodes.some(n => n.next?.includes(node.id));
const content = node.content ? node.content({ node }) : null;
const hasContent = !!content;
const hasChildren = !!node.children?.length;
return (
<div
key={node.id}
ref={ref => {
if (ref && !nodeSizes[node.id]) {
setNodeSizes(prev => ({
...prev,
[node.id]: { width: ref.clientWidth, height: ref.clientHeight },
}));
}
}}
className={cn(
'bg-card transition-color absolute flex grid min-w-72 grid-cols-1 grid-rows-1 justify-start gap-2 rounded-lg border p-2 text-sm shadow-sm',
{
'w-72': !hasChildren,
'grid-rows-[auto_1fr]': hasContent || hasChildren,
'grid-rows-[auto_auto_1fr]': hasContent && hasChildren,
'rounded-xl border-dashed bg-transparent shadow-none': hasChildren,
},
)}
style={{
left: node.x - node.width / 2,
top: node.y - node.height / 2,
minWidth: Math.min(Math.max(node.width, 256), node.maxWidth ?? Infinity),
minHeight: node.height,
maxWidth: node.maxWidth,
}}
>
<div className="flex w-full items-center gap-2">
{node.icon ? node.icon({ className: 'size-4 text-secondary-foreground' }) : null}
<span className="font-medium">{node.title}</span>
<div className="ml-auto">
{node.headerSuffix ? node.headerSuffix({ node }) : null}
</div>
</div>
<div className="bg-secondary w-full rounded-sm p-2 empty:hidden">
{node.content ? node.content({ node }) : null}
</div>
{!!node.children?.length && (
<div className="size-full rounded-sm">
<Flow
nodes={node.children}
margin={0}
gapX={24}
gapY={16}
onGraphLayout={graph => {
const { width, height } = graph.graph();
setNodeSizes(prev => ({
...prev,
[node.id]: {
width: width + 20,
height: node.height + height + 4,
},
}));
}}
disableBackground
disableGestures
className="bg-transparent"
isChild
/>
</div>
)}
{hasFollowers && (
<div className="border-border bg-background absolute left-full top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-all" />
)}
{hasPrevious && (
<div className="border-border bg-background absolute left-0 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-all" />
)}
</div>
);
})}
<svg
className="pointer-events-none absolute left-0 top-0 -z-10"
style={{ width: graphSize.width, height: graphSize.height }}
>
{edges.filter(Boolean).map(edge => {
const fromNode = nodes.find(node => node.id === edge.from);
const toNode = nodes.find(node => node.id === edge.to);
if (!fromNode || !toNode) {
return null;
}
return (
<path
key={edge.from + edge.to}
className="stroke-border animate-dash transition-color animate-[dash_500ms_linear_infinite] fill-none stroke-2 [stroke-dasharray:12_8]"
d={roundedOrthogonalPath(
orthogonalPoints(
{
x: fromNode.x + fromNode.width / 2,
y: fromNode.y,
},
{
x: toNode.x - toNode.width / 2,
y: toNode.y,
},
),
4,
)}
/>
);
})}
</svg>
</div>
{!props.isChild && (
<div className="absolute left-4 top-4 z-10 flex items-center gap-2">
<div className="bg-card grid w-96 grid-cols-[1fr_auto_auto] items-center gap-2 rounded-lg border p-2 shadow-sm">
<div className="flex flex-1 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView(prev => ({ ...prev, scale: prev.scale - ZOOM_STEP }))}
>
<ZoomOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom out</TooltipContent>
</Tooltip>
<Slider
value={[view.scale]}
onValueChange={value => setView(prev => ({ ...prev, scale: value[0] }))}
min={MIN_SCALE}
max={MAX_SCALE}
step={ZOOM_STEP}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView(prev => ({ ...prev, scale: prev.scale + ZOOM_STEP }))}
>
<ZoomInIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom in</TooltipContent>
</Tooltip>
</div>
<Separator orientation="vertical" className="h-6!" />
<Button variant="ghost" size="sm" onClick={fitInView}>
<MaximizeIcon className="size-4" />
Fit in view
</Button>
</div>
</div>
)}
</div>
);
};

View file

@ -21,7 +21,7 @@ import {
SearchIcon,
TextAlignStartIcon,
} from 'lucide-react';
import { ToggleGroup, ToggleGroupItem } from '@/laboratory/components/ui/toggle-group';
import { toast } from 'sonner';
import type { LaboratoryOperation } from '../../lib/operations';
import {
getFieldByPath,
@ -40,6 +40,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '..
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group';
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useLaboratory } from './context';
@ -48,6 +49,7 @@ export const BuilderArgument = (props: {
path: string[];
isReadOnly?: boolean;
operation?: LaboratoryOperation | null;
operationName?: string | null;
}) => {
const {
schema,
@ -89,9 +91,18 @@ export const BuilderArgument = (props: {
}
if (checked) {
addArgToActiveOperation(props.path.join('.'), props.field.name, schema);
addArgToActiveOperation(
props.path.join('.'),
props.field.name,
schema,
props.operationName,
);
} else {
deleteArgFromActiveOperation(props.path.join('.'), props.field.name);
deleteArgFromActiveOperation(
props.path.join('.'),
props.field.name,
props.operationName,
);
}
}}
/>
@ -111,6 +122,7 @@ export const BuilderScalarField = (props: {
isSearchActive?: boolean;
isReadOnly?: boolean;
operation?: LaboratoryOperation | null;
operationName?: string | null;
searchValue?: string;
label?: React.ReactNode;
disableChildren?: boolean;
@ -140,16 +152,18 @@ export const BuilderScalarField = (props: {
);
const isInQuery = useMemo(() => {
return isPathInQuery(operation?.query ?? '', path);
}, [operation?.query, path]);
return isPathInQuery(operation?.query ?? '', path, props.operationName);
}, [operation?.query, path, props.operationName]);
const args = useMemo(() => {
return (props.field as GraphQLField<unknown, unknown, unknown>).args ?? [];
}, [props.field]);
const hasArgs = useMemo(() => {
return args.some(arg => isArgInQuery(operation?.query ?? '', path, arg.name));
}, [operation?.query, args, path]);
return args.some(arg =>
isArgInQuery(operation?.query ?? '', path, arg.name, props.operationName),
);
}, [operation?.query, args, path, props.operationName]);
const shouldHighlight = useMemo(() => {
const splittedName = splitIdentifier(props.field.name);
@ -185,9 +199,9 @@ export const BuilderScalarField = (props: {
onCheckedChange={checked => {
if (checked) {
setIsOpen(true);
addPathToActiveOperation(path);
addPathToActiveOperation(path, props.operationName);
} else {
deletePathFromActiveOperation(path);
deletePathFromActiveOperation(path, props.operationName);
}
}}
/>
@ -237,9 +251,9 @@ export const BuilderScalarField = (props: {
onCheckedChange={checked => {
if (checked) {
setIsOpen(true);
addPathToActiveOperation(path);
addPathToActiveOperation(path, props.operationName);
} else {
deletePathFromActiveOperation(path);
deletePathFromActiveOperation(path, props.operationName);
}
}}
/>
@ -321,9 +335,9 @@ export const BuilderScalarField = (props: {
disabled={activeTab?.type !== 'operation'}
onCheckedChange={checked => {
if (checked) {
addPathToActiveOperation(props.path.join('.'));
addPathToActiveOperation(props.path.join('.'), props.operationName);
} else {
deletePathFromActiveOperation(props.path.join('.'));
deletePathFromActiveOperation(props.path.join('.'), props.operationName);
}
}}
/>
@ -352,6 +366,7 @@ export const BuilderObjectField = (props: {
isSearchActive?: boolean;
isReadOnly?: boolean;
operation?: LaboratoryOperation | null;
operationName?: string | null;
searchValue?: string;
label?: React.ReactNode;
disableChildren?: boolean;
@ -441,9 +456,9 @@ export const BuilderObjectField = (props: {
onCheckedChange={checked => {
if (checked) {
setIsOpen(true);
addPathToActiveOperation(path);
addPathToActiveOperation(path, props.operationName);
} else {
deletePathFromActiveOperation(path);
deletePathFromActiveOperation(path, props.operationName);
}
}}
/>
@ -492,9 +507,9 @@ export const BuilderObjectField = (props: {
onCheckedChange={checked => {
if (checked) {
setIsOpen(true);
addPathToActiveOperation(path);
addPathToActiveOperation(path, props.operationName);
} else {
deletePathFromActiveOperation(path);
deletePathFromActiveOperation(path, props.operationName);
}
}}
/>
@ -564,6 +579,7 @@ export const BuilderObjectField = (props: {
isSearchActive={props.isSearchActive}
isReadOnly={props.isReadOnly}
operation={operation}
operationName={props.operationName}
searchValue={props.searchValue}
/>
))}
@ -583,6 +599,7 @@ export const BuilderField = (props: {
forcedOpenPaths?: Set<string> | null;
isSearchActive?: boolean;
operation?: LaboratoryOperation | null;
operationName?: string | null;
isReadOnly?: boolean;
searchValue?: string;
label?: React.ReactNode;
@ -609,6 +626,7 @@ export const BuilderField = (props: {
isSearchActive={props.isSearchActive}
isReadOnly={props.isReadOnly}
operation={props.operation}
operationName={props.operationName}
searchValue={props.searchValue}
label={props.label}
disableChildren={props.disableChildren}
@ -627,6 +645,7 @@ export const BuilderField = (props: {
isSearchActive={props.isSearchActive}
isReadOnly={props.isReadOnly}
operation={props.operation}
operationName={props.operationName}
searchValue={props.searchValue}
label={props.label}
disableChildren={props.disableChildren}
@ -651,6 +670,7 @@ export const BuilderSearchResults = (props: {
mode: BuilderSearchResultMode;
isReadOnly: boolean;
operation: LaboratoryOperation | null;
operationName?: string | null;
searchValue: string;
schema: GraphQLSchema;
tab: OperationTypeNode;
@ -675,6 +695,7 @@ export const BuilderSearchResults = (props: {
isSearchActive={props.isSearchActive}
isReadOnly={props.isReadOnly}
operation={props.operation}
operationName={props.operationName}
searchValue={props.searchValue}
disableChildren
label={
@ -726,6 +747,7 @@ export const BuilderSearchResults = (props: {
isSearchActive={props.isSearchActive}
isReadOnly={props.isReadOnly}
operation={props.operation}
operationName={props.operationName}
searchValue={props.searchValue}
/>
);
@ -734,6 +756,7 @@ export const BuilderSearchResults = (props: {
export const Builder = (props: {
operation?: LaboratoryOperation | null;
operationName?: string | null;
isReadOnly?: boolean;
}) => {
const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = useLaboratory();
@ -791,8 +814,6 @@ export const Builder = (props: {
});
}, [schema, deferredSearchValue, isSearchActive, tabValue]);
console.log(searchResult);
const visiblePaths = isSearchActive ? (searchResult?.visiblePaths ?? null) : null;
const forcedOpenPaths =
isSearchActive && deferredSearchValue.includes('.')
@ -818,6 +839,8 @@ export const Builder = (props: {
const restoreEndpoint = useCallback(() => {
setEndpointValue(endpoint ?? '');
setEndpoint(defaultEndpoint ?? '');
toast.success('Endpoint restored to default');
}, [defaultEndpoint, setEndpointValue]);
return (
@ -853,14 +876,18 @@ export const Builder = (props: {
</InputGroupAddon>
{defaultEndpoint && (
<InputGroupAddon align="inline-end">
<InputGroupButton className="rounded-full" size="icon-xs" onClick={restoreEndpoint}>
<Tooltip>
<TooltipTrigger>
<Tooltip>
<TooltipTrigger>
<InputGroupButton
className="rounded-full"
size="icon-xs"
onClick={restoreEndpoint}
>
<RotateCcwIcon className="size-4" />
</TooltipTrigger>
<TooltipContent>Restore default endpoint</TooltipContent>
</Tooltip>
</InputGroupButton>
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>Restore default endpoint</TooltipContent>
</Tooltip>
</InputGroupAddon>
)}
</InputGroup>
@ -975,6 +1002,7 @@ export const Builder = (props: {
isSearchActive={isSearchActive}
isReadOnly={props.isReadOnly}
operation={operation}
operationName={props.operationName}
searchValue={deferredSearchValue}
/>
))
@ -1011,6 +1039,7 @@ export const Builder = (props: {
isSearchActive={isSearchActive}
isReadOnly={props.isReadOnly}
operation={operation}
operationName={props.operationName}
searchValue={deferredSearchValue}
/>
))
@ -1047,6 +1076,7 @@ export const Builder = (props: {
isSearchActive={isSearchActive}
isReadOnly={props.isReadOnly}
operation={operation}
operationName={props.operationName}
searchValue={deferredSearchValue}
/>
))

View file

@ -1,12 +1,20 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
CheckIcon,
FolderIcon,
FolderOpenIcon,
FolderPlusIcon,
PencilIcon,
SearchIcon,
TrashIcon,
XIcon,
} from 'lucide-react';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@/components/ui/input-group';
import { TooltipTrigger } from '@radix-ui/react-tooltip';
import type { LaboratoryCollection, LaboratoryCollectionOperation } from '../../lib/collections';
import { cn } from '../../lib/utils';
@ -44,6 +52,7 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
addOperation,
setActiveOperation,
deleteCollection,
updateCollection,
deleteOperationFromCollection,
addTab,
setActiveTab,
@ -51,67 +60,155 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
} = useLaboratory();
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editedName, setEditedName] = useState(props.collection.name);
const hasActiveOperation = useMemo(() => {
return props.collection.operations.some(operation => operation.id === activeOperation?.id);
}, [props.collection.operations, activeOperation]);
useEffect(() => {
if (hasActiveOperation) {
setIsOpen(true);
}
}, [hasActiveOperation]);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="bg-background group sticky top-0 w-full justify-start px-2"
size="sm"
>
{isOpen ? (
<FolderOpenIcon className="text-muted-foreground size-4" />
) : (
<FolderIcon className="text-muted-foreground size-4" />
)}
{props.collection.name}
{checkPermissions?.('collections:delete') && (
<Tooltip>
<TooltipTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
{isEditing ? (
<InputGroup className="!bg-accent/50 h-8 border-none">
<InputGroupAddon className="pl-2.5">
{isOpen ? (
<FolderOpenIcon className="text-muted-foreground size-4" />
) : (
<FolderIcon className="text-muted-foreground size-4" />
)}
</InputGroupAddon>
<InputGroupInput
autoFocus
defaultValue={editedName}
className="!pl-1.5 font-medium"
onChange={e => setEditedName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
updateCollection(props.collection.id, {
name: editedName,
});
setIsEditing(false);
}
if (e.key === 'Escape') {
setEditedName(props.collection.name);
setIsEditing(false);
}
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
className="p-1!"
onClick={e => {
e.stopPropagation();
updateCollection(props.collection.id, {
name: editedName,
});
setIsEditing(false);
}}
>
<CheckIcon />
</InputGroupButton>
<InputGroupButton
className="p-1!"
onClick={e => {
e.stopPropagation();
setIsEditing(false);
setEditedName(props.collection.name);
}}
>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
) : (
<Button
variant="ghost"
className="bg-background !hover:bg-accent/50 group sticky top-0 w-full justify-start px-2"
size="sm"
>
{isOpen ? (
<FolderOpenIcon className="text-muted-foreground size-4" />
) : (
<FolderIcon className="text-muted-foreground size-4" />
)}
{props.collection.name}
<div className="ml-auto flex items-center gap-2">
{checkPermissions?.('collections:update') && (
<Tooltip>
<TooltipTrigger>
<Button
variant="link"
className="text-muted-foreground hover:text-destructive p-1! pr-0! ml-auto opacity-0 transition-opacity group-hover:opacity-100"
className="text-muted-foreground p-1! pr-0! opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => {
e.stopPropagation();
setIsEditing(true);
}}
>
<TrashIcon />
<PencilIcon />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete collection?
</AlertDialogTitle>
<AlertDialogDescription>
{props.collection.name} will be permanently deleted. All operations in this
collection will be deleted as well.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
</TooltipTrigger>
<TooltipContent>Edit collection</TooltipContent>
</Tooltip>
)}
{checkPermissions?.('collections:delete') && (
<Tooltip>
<TooltipTrigger>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
variant="link"
className="text-muted-foreground hover:text-destructive p-1! pr-0! opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => {
e.stopPropagation();
deleteCollection(props.collection.id);
}}
>
Delete
<TrashIcon />
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>Delete collection</TooltipContent>
</Tooltip>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete collection?
</AlertDialogTitle>
<AlertDialogDescription>
{props.collection.name} will be permanently deleted. All operations in
this collection will be deleted as well.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="destructive"
onClick={e => {
e.stopPropagation();
deleteCollection(props.collection.id);
}}
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>Delete collection</TooltipContent>
</Tooltip>
)}
</div>
</Button>
)}
</CollapsibleTrigger>
<CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
{isOpen &&

View file

@ -1,5 +1,16 @@
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { OperationDefinitionNode, parse } from 'graphql';
import * as monaco from 'monaco-editor';
import { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js';
import { initializeMode } from 'monaco-graphql/initializeMode';
import MonacoEditor, { loader } from '@monaco-editor/react';
import { useLaboratory } from './context';
@ -121,46 +132,62 @@ export type EditorProps = React.ComponentProps<typeof MonacoEditor> & {
uri?: monaco.Uri;
variablesUri?: monaco.Uri;
extraLibs?: string[];
onOperationNameChange?: (operationName: string | null) => void;
};
const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
const id = useId();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const { introspection, endpoint, theme } = useLaboratory();
const wantsJson = props.language === 'json' || props.defaultLanguage === 'json';
const [typescriptReady, setTypescriptReady] = useState(!!monaco.languages.typescript);
const [jsonReady, setJsonReady] = useState(
monaco.languages.getLanguages().some(language => language.id === 'json'),
);
const apiRef = useRef<MonacoGraphQLAPI | null>(null);
useEffect(() => {
if (introspection) {
const api = initializeMode({
schemas: [
if (apiRef.current) {
apiRef.current.setSchemaConfig([
{
introspectionJSON: introspection,
uri: `schema_${endpoint}.graphql`,
},
],
diagnosticSettings:
props.uri && props.variablesUri
? {
validateVariablesJSON: {
[props.uri.toString()]: [props.variablesUri.toString()],
},
jsonDiagnosticSettings: {
allowComments: true, // allow json, parse with a jsonc parser to make requests
},
}
: undefined,
});
]);
} else {
apiRef.current = initializeMode({
schemas: [
{
introspectionJSON: introspection,
uri: `schema_${endpoint}.graphql`,
},
],
diagnosticSettings:
props.uri && props.variablesUri
? {
validateVariablesJSON: {
[props.uri.toString()]: [props.variablesUri.toString()],
},
jsonDiagnosticSettings: {
allowComments: true, // allow json, parse with a jsonc parser to make requests
},
}
: undefined,
});
api.setCompletionSettings({
__experimental__fillLeafsOnComplete: true,
});
apiRef.current.setCompletionSettings({
__experimental__fillLeafsOnComplete: true,
});
}
}
}, [introspection, props.uri?.toString(), props.variablesUri?.toString()]);
}, [endpoint, introspection, props.uri?.toString(), props.variablesUri?.toString()]);
useEffect(() => {
void (async function () {
if (!props.extraLibs?.length) {
return;
if (wantsJson && !jsonReady) {
await import('monaco-editor/esm/vs/language/json/monaco.contribution');
setJsonReady(true);
}
if (!monaco.languages.typescript) {
@ -168,6 +195,10 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
setTypescriptReady(true);
}
if (!props.extraLibs?.length) {
return;
}
const ts = monaco.languages.typescript;
if (!ts) {
@ -197,7 +228,7 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
})),
);
})();
}, [id, props.extraLibs]);
}, [id, jsonReady, props.extraLibs, wantsJson]);
useImperativeHandle(
ref,
@ -211,19 +242,124 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
[],
);
const setupDecorationsHandler = useCallback(
(editor: monaco.editor.IStandaloneCodeEditor) => {
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | null = null;
const handler = () => {
decorationsCollection?.clear();
try {
const value = editor.getValue();
const doc = parse(value);
const definition = doc.definitions.find(definition => {
if (definition.kind !== 'OperationDefinition') {
return false;
}
if (!definition.loc) {
return false;
}
const cursorPosition = editor.getPosition();
if (cursorPosition) {
return (
definition.loc.startToken.line <= cursorPosition.lineNumber &&
definition.loc.endToken.line >= cursorPosition.lineNumber
);
}
});
if (definition?.loc) {
const decorations: monaco.editor.IModelDeltaDecoration[] = [];
if (definition.loc.startToken.line > 1) {
decorations.push({
range: new monaco.Range(
0,
0,
definition.loc.startToken.line - 1,
definition.loc.startToken.column,
),
options: {
isWholeLine: true,
inlineClassName: 'inactive-line',
},
});
}
const lineCount = editor.getModel()?.getLineCount() ?? 0;
const lastLineMaxColumn = editor.getModel()?.getLineMaxColumn(lineCount) ?? 0;
if (definition.loc.endToken.line < lineCount) {
decorations.push({
range: new monaco.Range(
definition.loc.endToken.line + 1,
definition.loc.endToken.column,
lineCount,
lastLineMaxColumn,
),
options: {
isWholeLine: true,
inlineClassName: 'inactive-line',
},
});
}
decorationsCollection = editor.createDecorationsCollection(decorations);
props.onOperationNameChange?.(
(definition as OperationDefinitionNode).name?.value ?? null,
);
}
} catch (error) {}
};
editor.onDidChangeCursorPosition(handler);
handler();
},
[props.onOperationNameChange],
);
const handleMount = useCallback(
(editor: monaco.editor.IStandaloneCodeEditor) => {
editorRef.current = editor;
setupDecorationsHandler(editor);
},
[setupDecorationsHandler],
);
const recentCursorPosition = useRef<{ lineNumber: number; column: number } | null>(null);
useLayoutEffect(() => {
recentCursorPosition.current = editorRef.current?.getPosition() ?? null;
}, [props.value]);
useEffect(() => {
if (editorRef.current && recentCursorPosition.current) {
editorRef.current.setPosition(recentCursorPosition.current);
recentCursorPosition.current = null;
}
}, [props.value]);
if (!typescriptReady && props.language === 'typescript') {
return null;
}
if (!jsonReady && wantsJson) {
return null;
}
return (
<div className="size-full overflow-hidden">
<MonacoEditor
className="size-full"
{...props}
theme={theme === 'dark' ? 'hive-laboratory-dark' : 'hive-laboratory-light'}
onMount={editor => {
editorRef.current = editor;
}}
onMount={handleMount}
loading={null}
options={{
...props.options,

View file

@ -433,7 +433,7 @@ const LaboratoryContent = () => {
<div className="w-full">
<Tabs />
</div>
<div className="bg-card flex-1 overflow-hidden">{contentNode}</div>
<div className="bg-card relative flex-1 overflow-hidden">{contentNode}</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
@ -514,7 +514,10 @@ export const Laboratory = (
const pluginsApi = usePlugins(props);
const testsApi = useTests(props);
const tabsApi = useTabs(props);
const endpointApi = useEndpoint(props);
const endpointApi = useEndpoint({
...props,
settingsApi,
});
const collectionsApi = useCollections({
...props,
tabsApi,
@ -623,7 +626,7 @@ export const Laboratory = (
[],
);
const containerRef = useRef<HTMLDivElement>(null);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
@ -642,160 +645,9 @@ export const Laboratory = (
className={cn('hive-laboratory bg-background size-full', props.theme, {
'fixed inset-0 z-50': isFullScreen,
})}
ref={containerRef}
ref={setContainer}
>
<Toaster richColors closeButton position="top-right" theme={props.theme} />
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Update endpoint</DialogTitle>
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="update-endpoint-form"
onSubmit={e => {
e.preventDefault();
void updateEndpointForm.handleSubmit();
}}
>
<FieldGroup>
<updateEndpointForm.Field name="endpoint">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter endpoint"
autoComplete="off"
/>
);
}}
</updateEndpointForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="update-endpoint-form">
Update endpoint
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PreflightPromptModal
open={isPreflightPromptModalOpen}
onOpenChange={setIsPreflightPromptModalOpen}
{...preflightPromptModalProps}
/>
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add collection</DialogTitle>
<DialogDescription>
Add a new collection of operations to your laboratory.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-collection-form"
onSubmit={e => {
e.preventDefault();
void addCollectionForm.handleSubmit();
}}
>
<FieldGroup>
<addCollectionForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the collection"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addCollectionForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-collection-form">
Add collection
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add test</DialogTitle>
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-test-form"
onSubmit={e => {
e.preventDefault();
void addTestForm.handleSubmit();
}}
>
<FieldGroup>
<addTestForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the test"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addTestForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-test-form">
Add test
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<LaboratoryProvider
{...props}
@ -809,7 +661,7 @@ export const Laboratory = (
{...collectionsApi}
{...operationsApi}
{...historyApi}
container={containerRef.current}
container={container}
openAddCollectionDialog={openAddCollectionDialog}
openUpdateEndpointDialog={openUpdateEndpointDialog}
openAddTestDialog={openAddTestDialog}
@ -819,6 +671,157 @@ export const Laboratory = (
isFullScreen={isFullScreen}
checkPermissions={checkPermissions}
>
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Update endpoint</DialogTitle>
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="update-endpoint-form"
onSubmit={e => {
e.preventDefault();
void updateEndpointForm.handleSubmit();
}}
>
<FieldGroup>
<updateEndpointForm.Field name="endpoint">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter endpoint"
autoComplete="off"
/>
);
}}
</updateEndpointForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="update-endpoint-form">
Update endpoint
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PreflightPromptModal
open={isPreflightPromptModalOpen}
onOpenChange={setIsPreflightPromptModalOpen}
{...preflightPromptModalProps}
/>
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add collection</DialogTitle>
<DialogDescription>
Add a new collection of operations to your laboratory.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-collection-form"
onSubmit={e => {
e.preventDefault();
void addCollectionForm.handleSubmit();
}}
>
<FieldGroup>
<addCollectionForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the collection"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addCollectionForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-collection-form">
Add collection
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add test</DialogTitle>
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-test-form"
onSubmit={e => {
e.preventDefault();
void addTestForm.handleSubmit();
}}
>
<FieldGroup>
<addTestForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the test"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addTestForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-test-form">
Add test
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<LaboratoryContent />
</LaboratoryProvider>
</div>

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AlignLeftIcon,
BookmarkIcon,
CircleCheckIcon,
CircleXIcon,
@ -7,6 +8,9 @@ import {
FileTextIcon,
HistoryIcon,
MoreHorizontalIcon,
NetworkIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlayIcon,
PowerIcon,
PowerOffIcon,
@ -16,6 +20,9 @@ import { compressToEncodedURIComponent } from 'lz-string';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { toast } from 'sonner';
import { z } from 'zod';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { QueryPlanSchema } from '@/lib/query-plan/schema';
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
import { useForm } from '@tanstack/react-form';
import type {
@ -24,6 +31,7 @@ import type {
LaboratoryHistorySubscription,
} from '../../lib/history';
import type { LaboratoryOperation } from '../../lib/operations';
import { QueryPlanTree, renderQueryPlan } from '../../lib/query-plan/utils';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
@ -38,13 +46,14 @@ import {
} from '../ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from '../ui/dropdown-menu';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
import { Field, FieldGroup, FieldLabel } from '../ui/field';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Spinner } from '../ui/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Toggle } from '../ui/toggle';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import { Builder } from './builder';
import { useLaboratory } from './context';
import { Editor } from './editor';
@ -86,6 +95,7 @@ const Headers = (props: { operation?: LaboratoryOperation | null; isReadOnly?: b
<Editor
uri={monaco.Uri.file('headers.json')}
value={operation?.headers ?? ''}
language="json"
onChange={value => {
updateActiveOperation({
headers: value ?? '',
@ -109,6 +119,7 @@ const Extensions = (props: { operation?: LaboratoryOperation | null; isReadOnly?
<Editor
uri={monaco.Uri.file('extensions.json')}
value={operation?.extensions ?? ''}
language="json"
onChange={value => {
updateActiveOperation({
extensions: value ?? '',
@ -178,6 +189,56 @@ export const ResponsePreflight = ({ historyItem }: { historyItem?: LaboratoryHis
</ScrollArea>
);
};
export const ResponseQueryPlan = ({ historyItem }: { historyItem?: LaboratoryHistory | null }) => {
const [mode, setMode] = useState<'text' | 'visual'>('text');
const queryPlan = useMemo(() => {
try {
const queryPlan =
JSON.parse((historyItem as LaboratoryHistoryRequest)?.response ?? '{}').extensions
?.queryPlan ?? {};
if (!queryPlan) {
return null;
}
return QueryPlanSchema.safeParse(queryPlan).success ? queryPlan : null;
} catch {
return null;
}
}, [historyItem]);
return (
<div className="relative size-full">
<ToggleGroup
className="bg-card absolute right-4 top-4 z-10 shadow-sm"
type="single"
variant="outline"
value={mode}
onValueChange={value => setMode(value as 'text' | 'visual')}
>
<ToggleGroupItem value="text">
<AlignLeftIcon className="size-4" />
Text
</ToggleGroupItem>
<ToggleGroupItem value="visual">
<NetworkIcon className="size-4" />
Visual
</ToggleGroupItem>
</ToggleGroup>
{mode === 'visual' ? (
<QueryPlanTree key={historyItem?.id} plan={queryPlan} />
) : (
<Editor
value={renderQueryPlan(queryPlan)}
defaultLanguage="graphql"
theme="hive-laboratory"
options={{ readOnly: true }}
/>
)}
</div>
);
};
export const ResponseSubscription = ({
historyItem,
@ -245,6 +306,8 @@ export const ResponseSubscription = ({
};
export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryRequest | null }) => {
const [isFullScreen, setIsFullScreen] = useState(false);
const isError = useMemo(() => {
if (!historyItem) {
return false;
@ -261,12 +324,65 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
);
}, [historyItem]);
const hasValidQueryPlan = useMemo(() => {
if (!historyItem) {
return false;
}
const queryPlan = JSON.parse(historyItem.response).extensions?.queryPlan;
if (!queryPlan) {
return false;
}
return QueryPlanSchema.safeParse(queryPlan).success;
}, [historyItem?.response]);
return (
<Tabs defaultValue="response" className="grid size-full grid-rows-[auto_1fr]">
<TabsList className="h-[49.5px] w-full justify-start rounded-none border-b bg-transparent p-3">
<Tabs
defaultValue="response"
className={cn('bg-card grid size-full grid-rows-[auto_1fr]', {
'z-100 absolute inset-0 size-full': isFullScreen,
})}
>
<TabsList className="h-[50px] w-full items-center justify-start rounded-none border-b bg-transparent p-3">
{isFullScreen ? (
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="mr-2 mt-0.5 h-6 w-6"
onClick={() => setIsFullScreen(false)}
>
<PanelLeftOpenIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Minimize panel</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="mr-2 mt-0.5 h-6 w-6"
onClick={() => setIsFullScreen(true)}
>
<PanelLeftCloseIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Maximize panel</TooltipContent>
</Tooltip>
)}
<TabsTrigger value="response" className="grow-0 rounded-sm">
Response
</TabsTrigger>
{hasValidQueryPlan && (
<TabsTrigger value="query-plan" className="grow-0 rounded-sm">
Query Plan
</TabsTrigger>
)}
<TabsTrigger value="headers" className="grow-0 rounded-sm">
Headers
</TabsTrigger>
@ -277,7 +393,7 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
)}
{historyItem ? (
<div className="ml-auto flex items-center gap-2">
{historyItem?.status && (
{!!historyItem?.status && (
<Badge
className={cn('bg-green-400/10 text-green-500', {
'bg-red-400/10 text-red-500': isError,
@ -315,6 +431,9 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
<TabsContent value="response" className="overflow-hidden">
<ResponseBody historyItem={historyItem} />
</TabsContent>
<TabsContent value="query-plan" className="overflow-hidden">
<ResponseQueryPlan historyItem={historyItem} />
</TabsContent>
<TabsContent value="headers" className="overflow-hidden">
<ResponseHeaders historyItem={historyItem} />
</TabsContent>
@ -332,6 +451,7 @@ const saveToCollectionFormSchema = z.object({
export const Query = (props: {
onAfterOperationRun?: (historyItem: LaboratoryHistory | null) => void;
operation?: LaboratoryOperation | null;
onOperationNameChange?: (operationName: string | null) => void;
isReadOnly?: boolean;
}) => {
const {
@ -342,6 +462,7 @@ export const Query = (props: {
updateActiveOperation,
collections,
addOperationToCollection,
addCollection,
addHistory,
stopActiveOperation,
addResponseToHistory,
@ -358,6 +479,8 @@ export const Query = (props: {
setPluginsState,
} = useLaboratory();
const [operationName, setOperationName] = useState<string | null>(null);
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
@ -401,6 +524,7 @@ export const Query = (props: {
void runActiveOperation(endpoint, {
env: result?.env,
headers: result?.headers,
operationName: operationName ?? undefined,
onResponse: data => {
addResponseToHistory(newItemHistory.id, data);
},
@ -413,22 +537,38 @@ export const Query = (props: {
const response = await runActiveOperation(endpoint, {
env: result?.env,
headers: result?.headers,
operationName: operationName ?? undefined,
});
if (!response) {
return;
}
const status = response.status;
const extensionsResponse = (response.extensions?.response as {
status: number;
headers: Record<string, string>;
}) ?? {
status: 0,
headers: {},
};
delete response.extensions?.request;
delete response.extensions?.response;
if (Object.keys(response.extensions ?? {}).length === 0) {
delete response.extensions;
}
const status = extensionsResponse.status;
const duration = performance.now() - startTime;
const responseText = await response.text();
const responseText = JSON.stringify(response, null, 2);
const size = responseText.length;
const newItemHistory = addHistory({
status,
duration,
size,
headers: JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2),
headers: JSON.stringify(extensionsResponse.headers, null, 2),
operation,
preflightLogs: result?.logs ?? [],
response: responseText,
@ -439,6 +579,7 @@ export const Query = (props: {
}
}, [
operation,
operationName,
endpoint,
isActiveOperationSubscription,
addHistory,
@ -477,15 +618,34 @@ export const Query = (props: {
return;
}
addOperationToCollection(value.collectionId, {
id: operation.id ?? '',
name: operation.name ?? '',
query: operation.query ?? '',
variables: operation.variables ?? '',
headers: operation.headers ?? '',
extensions: operation.extensions ?? '',
description: '',
});
const collection = collections.find(c => c.id === value.collectionId);
if (!collection) {
addCollection({
name: value.collectionId,
operations: [
{
id: operation.id ?? '',
name: operation.name ?? '',
query: operation.query ?? '',
variables: operation.variables ?? '',
headers: operation.headers ?? '',
extensions: operation.extensions ?? '',
description: '',
},
],
});
} else {
addOperationToCollection(value.collectionId, {
id: operation.id ?? '',
name: operation.name ?? '',
query: operation.query ?? '',
variables: operation.variables ?? '',
headers: operation.headers ?? '',
extensions: operation.extensions ?? '',
description: '',
});
}
setIsSaveToCollectionDialogOpen(false);
},
@ -529,10 +689,8 @@ export const Query = (props: {
<Dialog open={isSaveToCollectionDialogOpen} onOpenChange={setIsSaveToCollectionDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add collection</DialogTitle>
<DialogDescription>
Add a new collection of operations to your laboratory.
</DialogDescription>
<DialogTitle>Save operation to collection</DialogTitle>
<DialogDescription>Save the current operation to a collection.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
@ -543,33 +701,58 @@ export const Query = (props: {
}}
>
<FieldGroup>
<saveToCollectionForm.Field name="collectionId">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
{collections.length > 0 ? (
<saveToCollectionForm.Field name="collectionId">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
<SelectValue placeholder="Select collection" />
</SelectTrigger>
<SelectContent>
{collections.map(c => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}}
</saveToCollectionForm.Field>
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
<SelectValue placeholder="Select collection" />
</SelectTrigger>
<SelectContent>
{collections.map(c => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}}
</saveToCollectionForm.Field>
) : (
<saveToCollectionForm.Field name="collectionId">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>New collection name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the collection"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</saveToCollectionForm.Field>
)}
</FieldGroup>
</form>
</div>
@ -700,6 +883,10 @@ export const Query = (props: {
query: value ?? '',
});
}}
onOperationNameChange={operationName => {
setOperationName(operationName);
props.onOperationNameChange?.(operationName);
}}
language="graphql"
theme="hive-laboratory"
options={{
@ -715,6 +902,7 @@ export const Operation = (props: {
historyItem?: LaboratoryHistory;
}) => {
const { activeOperation, history } = useLaboratory();
const [operationName, setOperationName] = useState<string | null>(null);
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
@ -735,16 +923,20 @@ export const Operation = (props: {
}, [props.historyItem]);
return (
<div className="bg-card size-full">
<div className="bg-card relative size-full">
<ResizablePanelGroup direction="horizontal" className="size-full">
<ResizablePanel defaultSize={25}>
<Builder operation={operation} isReadOnly={isReadOnly} />
<Builder operation={operation} operationName={operationName} isReadOnly={isReadOnly} />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={40}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={70}>
<Query operation={operation} isReadOnly={isReadOnly} />
<Query
operation={operation}
isReadOnly={isReadOnly}
onOperationNameChange={setOperationName}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={30}>

View file

@ -1,4 +1,6 @@
import { z } from 'zod';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useForm } from '@tanstack/react-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldGroup, FieldLabel } from '../ui/field';
@ -8,6 +10,16 @@ import { useLaboratory } from './context';
const settingsFormSchema = z.object({
fetch: z.object({
credentials: z.enum(['include', 'omit', 'same-origin']),
timeout: z.number().optional(),
retry: z.number().optional(),
useGETForQueries: z.boolean().optional(),
}),
subscriptions: z.object({
protocol: z.enum(['SSE', 'GRAPHQL_SSE', 'WS', 'LEGACY_WS']),
}),
introspection: z.object({
method: z.enum(['GET', 'POST']).optional(),
schemaDescription: z.boolean().optional(),
}),
});
@ -25,12 +37,12 @@ export const Settings = () => {
});
return (
<div className="bg-card size-full p-3">
<div className="bg-card size-full overflow-y-auto p-3">
<form
id="settings-form"
onSubmit={form.handleSubmit}
onChange={form.handleSubmit}
className="mx-auto max-w-2xl"
className="mx-auto flex max-w-2xl flex-col gap-4"
>
<Card>
<CardHeader>
@ -66,6 +78,148 @@ export const Settings = () => {
);
}}
</form.Field>
<form.Field name="fetch.timeout">
{field => {
return (
<Field>
<FieldLabel htmlFor={field.name}>Timeout</FieldLabel>
<Input
type="number"
name={field.name}
value={field.state.value ?? ''}
onChange={e =>
field.handleChange(
e.target.value === '' ? undefined : Number(e.target.value),
)
}
/>
</Field>
);
}}
</form.Field>
<form.Field name="fetch.retry">
{field => {
return (
<Field>
<FieldLabel htmlFor={field.name}>Retry</FieldLabel>
<Input
type="number"
name={field.name}
value={field.state.value ?? ''}
onChange={e =>
field.handleChange(
e.target.value === '' ? undefined : Number(e.target.value),
)
}
/>
</Field>
);
}}
</form.Field>
<form.Field name="fetch.useGETForQueries">
{field => {
return (
<Field className="flex-row items-center">
<Switch
className="!w-8"
checked={field.state.value ?? false}
onCheckedChange={field.handleChange}
/>
<FieldLabel htmlFor={field.name}>Use GET for queries</FieldLabel>
</Field>
);
}}
</form.Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Subscriptions</CardTitle>
<CardDescription>
Configure the subscriptions options for the laboratory.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<form.Field name="subscriptions.protocol">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Protocol</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={value =>
field.handleChange(value as 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS')
}
>
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
<SelectValue placeholder="Select protocol" />
</SelectTrigger>
<SelectContent>
<SelectItem value="SSE">SSE</SelectItem>
<SelectItem value="GRAPHQL_SSE">GRAPHQL_SSE</SelectItem>
<SelectItem value="WS">WS</SelectItem>
<SelectItem value="LEGACY_WS">LEGACY_WS</SelectItem>
</SelectContent>
</Select>
</Field>
);
}}
</form.Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Introspection</CardTitle>
<CardDescription>
Configure the introspection options for the laboratory.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<form.Field name="introspection.method">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field>
<FieldLabel htmlFor={field.name}>Method</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={value => field.handleChange(value as 'GET' | 'POST')}
>
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
</Field>
);
}}
</form.Field>
<form.Field name="introspection.schemaDescription">
{field => {
return (
<Field className="flex-row items-center">
<Switch
className="!w-8"
checked={field.state.value ?? false}
onCheckedChange={field.handleChange}
/>
<FieldLabel htmlFor={field.name}>Schema description</FieldLabel>
</Field>
);
}}
</form.Field>
</FieldGroup>
</CardContent>
</Card>

View file

@ -1,5 +1,6 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '../../lib/utils';
import { useLaboratory } from '../laboratory/context';
import { buttonVariants } from './button';
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
@ -13,7 +14,11 @@ function AlertDialogTrigger({
}
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
const { container } = useLaboratory();
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} container={container} />
);
}
function AlertDialogOverlay({

View file

@ -9,7 +9,7 @@ const buttonVariants = cva(
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'!text-white hover:bg-destructive/90 focus-visible:ring-destructive/40 bg-destructive/60',
'!text-white !hover:bg-destructive/90 !focus-visible:ring-destructive/40 !bg-destructive/60',
outline:
'border shadow-sm hover:text-accent-foreground bg-input/30 border-input hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { XIcon } from 'lucide-react';
import { useLaboratory } from '@/components/laboratory/context';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '../../lib/utils';
@ -12,14 +12,9 @@ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const { container } = useLaboratory();
return (
<>
<DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />
<div ref={setContainer} style={{ display: 'contents' }} />
</>
);
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
@ -34,7 +29,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-150 fixed inset-0 bg-black/50',
className,
)}
{...props}
@ -56,7 +51,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-150 fixed left-[50%] top-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}

View file

@ -55,7 +55,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-200 relative max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,

View file

@ -0,0 +1,55 @@
import * as React from 'react';
import { Slider as SliderPrimitive } from 'radix-ui';
import { cn } from '../../lib/utils';
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50',
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5',
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View file

@ -3,7 +3,7 @@
import * as React from 'react';
import { type VariantProps } from 'class-variance-authority';
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
import { cn } from '../../../lib/utils';
import { cn } from '../../lib/utils';
import { toggleVariants } from './toggle';
const ToggleGroupContext = React.createContext<

View file

@ -3,7 +3,7 @@ import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cn } from '../../lib/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
"cursor-pointer inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useLaboratory } from '@/components/laboratory/context';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '../../lib/utils';
@ -33,7 +33,7 @@ function TooltipContent({
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const { container } = useLaboratory();
return (
<>
@ -42,7 +42,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-150 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
className,
)}
{...props}
@ -51,7 +51,6 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
<div ref={setContainer} style={{ display: 'contents' }} />
</>
);
}

View file

@ -42,6 +42,10 @@
height: 100%;
}
.hive-laboratory .inactive-line {
opacity: 0.5;
}
.hive-laboratory {
--color-neutral-1: 0 0% 99%;
--color-neutral-2: 180 9% 97%;
@ -57,14 +61,14 @@
--color-neutral-12: 175 23% 10%;
--color-accent: 206 96% 35%;
--color-ring: 216 58% 49%;
--color-destructive: 357 96% 58%;
--radius: var(--hive-laboratory-radius, 0.5rem);
--background: var(--hive-laboratory-background, var(--color-neutral-2));
--foreground: var(--hive-laboratory-foreground, var(--color-neutral-11));
--muted: var(--hive-laboratory-muted, 24 9.8% 10%);
--muted: var(--hive-laboratory-muted, var(--color-neutral-3));
--muted-foreground: var(--hive-laboratory-muted-foreground, var(--color-neutral-11));
--popover: var(--hive-laboratory-popover, var(--color-neutral-3));
@ -80,12 +84,12 @@
--primary-foreground: var(--hive-laboratory-primary-foreground, var(--color-neutral-1));
--secondary: var(--hive-laboratory-secondary, var(--color-neutral-3));
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-11));
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-8));
--accent: var(--hive-laboratory-accent, var(--color-neutral-4));
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
--destructive: var(--hive-laboratory-destructive, var(--red-500));
--destructive: var(--hive-laboratory-destructive, var(--color-destructive));
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
--ring: var(--hive-laboratory-ring, var(--color-ring));
@ -106,6 +110,7 @@
--color-neutral-12: 204 14% 93%;
--color-accent: 48 100% 83%;
--color-destructive: 358.75 100% 70%;
--radius: var(--hive-laboratory-radius, 0.5rem);
--background: var(--hive-laboratory-background, var(--color-neutral-1));
@ -132,7 +137,7 @@
--accent: var(--hive-laboratory-accent, var(--color-neutral-6));
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
--destructive: var(--hive-laboratory-destructive, var(--red-500));
--destructive: var(--hive-laboratory-destructive, var(--color-destructive));
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
--ring: var(--hive-laboratory-ring, var(--color-ring));
@ -159,3 +164,9 @@
@apply bg-background text-foreground;
}
}
@keyframes dash {
to {
stroke-dashoffset: -20;
}
}

View file

@ -1,5 +1,5 @@
import ReactDOM from 'react-dom/client';
import { Laboratory } from './components/laboratory/laboratory';
import { Laboratory, LaboratoryProps } from './components/laboratory/laboratory';
export * from './components/laboratory/laboratory';
export * from './components/laboratory/context';
@ -17,7 +17,7 @@ export * from './lib/tabs';
export * from './lib/tests';
export * from './lib/plugins';
export const renderLaboratory = (el: HTMLElement) => {
export const renderLaboratory = (el: HTMLElement, props: LaboratoryProps) => {
const prefix = 'hive-laboratory';
const getLocalStorage = (key: string) => {
@ -74,6 +74,7 @@ export const renderLaboratory = (el: HTMLElement) => {
onHistoryChange={history => {
setLocalStorage('history', history);
}}
{...props}
/>,
);
};

View file

@ -1,43 +0,0 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Toggle as TogglePrimitive } from 'radix-ui';
import { cn } from '../../../lib/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 min-w-9 px-2',
sm: 'h-8 min-w-8 px-1.5',
lg: 'h-10 min-w-10 px-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View file

@ -20,8 +20,10 @@ export interface LaboratoryCollection {
export interface LaboratoryCollectionsActions {
addCollection: (
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
) => void;
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'> & {
operations?: Omit<LaboratoryCollectionOperation, 'createdAt'>[];
},
) => LaboratoryCollection;
addOperationToCollection: (
collectionId: string,
operation: Omit<LaboratoryCollectionOperation, 'createdAt'>,
@ -30,7 +32,7 @@ export interface LaboratoryCollectionsActions {
deleteOperationFromCollection: (collectionId: string, operationId: string) => void;
updateCollection: (
collectionId: string,
collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>,
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
) => void;
updateOperationInCollection: (
collectionId: string,
@ -73,17 +75,27 @@ export const useCollections = (
);
const addCollection = useCallback(
(collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>) => {
(
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'> & {
operations?: Omit<LaboratoryCollectionOperation, 'createdAt'>[];
},
) => {
const newCollection: LaboratoryCollection = {
...collection,
id: uuidv4(),
createdAt: new Date().toISOString(),
operations: [],
operations:
collection.operations?.map(operation => ({
...operation,
createdAt: new Date().toISOString(),
})) ?? [],
};
const newCollections = [...collections, newCollection];
setCollections(newCollections);
props.onCollectionsChange?.(newCollections);
props.onCollectionCreate?.(newCollection);
return newCollection;
},
[collections, props],
);
@ -94,6 +106,7 @@ export const useCollections = (
...operation,
createdAt: new Date().toISOString(),
};
const newCollections = collections.map(collection =>
collection.id === collectionId
? {
@ -105,7 +118,9 @@ export const useCollections = (
setCollections(newCollections);
props.onCollectionsChange?.(newCollections);
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
if (updatedCollection) {
props.onCollectionUpdate?.(updatedCollection);
props.onCollectionOperationCreate?.(updatedCollection, newOperation);
@ -158,7 +173,10 @@ export const useCollections = (
);
const updateCollection = useCallback(
(collectionId: string, collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>) => {
(
collectionId: string,
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
) => {
const newCollections = collections.map(c =>
c.id === collectionId ? { ...c, ...collection } : c,
);

View file

@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
buildClientSchema,
getIntrospectionQuery,
GraphQLSchema,
introspectionFromSchema,
type IntrospectionQuery,
} from 'graphql';
import { toast } from 'sonner';
// import z from 'zod';
import { asyncInterval } from '@/lib/utils';
import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
export interface LaboratoryEndpointState {
endpoint: string | null;
@ -24,6 +28,7 @@ export const useEndpoint = (props: {
defaultEndpoint?: string | null;
onEndpointChange?: (endpoint: string | null) => void;
defaultSchemaIntrospection?: IntrospectionQuery | null;
settingsApi?: LaboratorySettingsState & LaboratorySettingsActions;
}): LaboratoryEndpointState & LaboratoryEndpointActions => {
const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null);
const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null);
@ -40,35 +45,104 @@ export const useEndpoint = (props: {
return introspection ? buildClientSchema(introspection) : null;
}, [introspection]);
const fetchSchema = useCallback(async () => {
if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) {
setIntrospection(props.defaultSchemaIntrospection);
const loader = useMemo(() => new UrlLoader(), []);
const fetchSchema = useCallback(
async (signal?: AbortSignal) => {
if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) {
setIntrospection(props.defaultSchemaIntrospection);
return;
}
if (!endpoint) {
setIntrospection(null);
return;
}
try {
const result = await loader.load(endpoint, {
subscriptionsEndpoint: endpoint,
subscriptionsProtocol:
(props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
SubscriptionProtocol.GRAPHQL_SSE,
credentials: props.settingsApi?.settings.fetch.credentials,
specifiedByUrl: true,
directiveIsRepeatable: true,
inputValueDeprecation: true,
retry: props.settingsApi?.settings.fetch.retry,
timeout: props.settingsApi?.settings.fetch.timeout,
useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries,
exposeHTTPDetailsInExtensions: true,
descriptions: props.settingsApi?.settings.introspection.schemaDescription ?? false,
method: props.settingsApi?.settings.introspection.method ?? 'POST',
fetch: (input: string | URL | Request, init?: RequestInit) =>
fetch(input, {
...init,
signal,
}),
});
if (result.length === 0) {
throw new Error('Failed to fetch schema');
}
if (!result[0].schema) {
throw new Error('Failed to fetch schema');
}
setIntrospection(introspectionFromSchema(result[0].schema));
} catch (error: unknown) {
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string'
) {
toast.error(error.message);
} else {
toast.error('Failed to fetch schema');
}
setIntrospection(null);
throw error;
}
},
[
endpoint,
props.settingsApi?.settings.fetch.timeout,
props.settingsApi?.settings.introspection.method,
props.settingsApi?.settings.introspection.schemaDescription,
],
);
const shouldPollSchema = useMemo(() => {
return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection;
}, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]);
useEffect(() => {
if (!shouldPollSchema || !endpoint) {
return;
}
if (!endpoint) {
setIntrospection(null);
return;
}
const intervalController = new AbortController();
try {
const response = await fetch(endpoint, {
method: 'POST',
body: JSON.stringify({
query: getIntrospectionQuery(),
}),
headers: {
'Content-Type': 'application/json',
},
}).then(r => r.json());
void asyncInterval(
async () => {
try {
await fetchSchema(intervalController.signal);
} catch {
intervalController.abort();
}
},
5000,
intervalController.signal,
);
setIntrospection(response.data as IntrospectionQuery);
} catch {
toast.error('Failed to fetch schema');
setIntrospection(null);
return;
}
}, [endpoint]);
return () => {
intervalController.abort();
};
}, [shouldPollSchema, fetchSchema]);
const restoreDefaultEndpoint = useCallback(() => {
if (props.defaultEndpoint) {
@ -77,10 +151,10 @@ export const useEndpoint = (props: {
}, [props.defaultEndpoint]);
useEffect(() => {
if (endpoint) {
if (endpoint && !shouldPollSchema) {
void fetchSchema();
}
}, [endpoint, fetchSchema]);
}, [endpoint, fetchSchema, shouldPollSchema]);
return {
endpoint,

View file

@ -1,8 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { GraphQLSchema } from 'graphql';
import { createClient } from 'graphql-ws';
import {
DocumentNode,
ExecutionResult,
getOperationAST,
GraphQLError,
Kind,
parse,
type GraphQLSchema,
} from 'graphql';
import { decompressFromEncodedURIComponent } from 'lz-string';
import { v4 as uuidv4 } from 'uuid';
import { isAsyncIterable } from '@/lib/utils';
import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
import { LaboratoryPermission, LaboratoryPermissions } from '../components/laboratory/context';
import type {
LaboratoryCollectionOperation,
@ -23,6 +32,14 @@ import type { LaboratoryPreflightActions, LaboratoryPreflightState } from './pre
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
import type { LaboratoryTabOperation, LaboratoryTabsActions, LaboratoryTabsState } from './tabs';
function getOperationType(query: string): 'query' | 'mutation' | 'subscription' | null {
try {
return getOperationAST(parse(query))?.operation ?? null;
} catch {
return null;
}
}
export interface LaboratoryOperation {
id: string;
name: string;
@ -45,18 +62,28 @@ export interface LaboratoryOperationsActions {
setOperations: (operations: LaboratoryOperation[]) => void;
updateActiveOperation: (operation: Partial<Omit<LaboratoryOperation, 'id'>>) => void;
deleteOperation: (operationId: string) => void;
addPathToActiveOperation: (path: string) => void;
deletePathFromActiveOperation: (path: string) => void;
addArgToActiveOperation: (path: string, argName: string, schema: GraphQLSchema) => void;
deleteArgFromActiveOperation: (path: string, argName: string) => void;
addPathToActiveOperation: (path: string, operationName?: string | null) => void;
deletePathFromActiveOperation: (path: string, operationName?: string | null) => void;
addArgToActiveOperation: (
path: string,
argName: string,
schema: GraphQLSchema,
operationName?: string | null,
) => void;
deleteArgFromActiveOperation: (
path: string,
argName: string,
operationName?: string | null,
) => void;
runActiveOperation: (
endpoint: string,
options?: {
env?: LaboratoryEnv;
headers?: Record<string, string>;
operationName?: string;
onResponse?: (response: string) => void;
},
) => Promise<Response | null>;
) => Promise<ExecutionResult | null>;
stopActiveOperation: (() => void) | null;
isActiveOperationLoading: boolean;
isOperationLoading: (operationId: string) => boolean;
@ -70,6 +97,27 @@ export interface LaboratoryOperationsCallbacks {
onOperationDelete?: (operation: LaboratoryOperation) => void;
}
const getOperationWithFragments = (
document: DocumentNode,
operationName?: string,
): DocumentNode => {
const definitions = document.definitions.filter(definition => {
if (
definition.kind === Kind.OPERATION_DEFINITION &&
operationName &&
definition.name?.value !== operationName
) {
return false;
}
return true;
});
return {
kind: Kind.DOCUMENT,
definitions,
};
};
export const useOperations = (
props: {
checkPermissions: (
@ -243,13 +291,13 @@ export const useOperations = (
);
const addPathToActiveOperation = useCallback(
(path: string) => {
(path: string, operationName?: string | null) => {
if (!activeOperation) {
return;
}
const newActiveOperation = {
...activeOperation,
query: addPathToQuery(activeOperation.query, path),
query: addPathToQuery(activeOperation.query, path, operationName),
};
updateActiveOperation(newActiveOperation);
},
@ -257,14 +305,14 @@ export const useOperations = (
);
const deletePathFromActiveOperation = useCallback(
(path: string) => {
(path: string, operationName?: string | null) => {
if (!activeOperation?.query) {
return;
}
const newActiveOperation = {
...activeOperation,
query: deletePathFromQuery(activeOperation.query, path),
query: deletePathFromQuery(activeOperation.query, path, operationName),
};
updateActiveOperation(newActiveOperation);
},
@ -272,14 +320,14 @@ export const useOperations = (
);
const addArgToActiveOperation = useCallback(
(path: string, argName: string, schema: GraphQLSchema) => {
(path: string, argName: string, schema: GraphQLSchema, operationName?: string | null) => {
if (!activeOperation?.query) {
return;
}
const newActiveOperation = {
...activeOperation,
query: addArgToField(activeOperation.query, path, argName, schema),
query: addArgToField(activeOperation.query, path, argName, schema, operationName),
};
updateActiveOperation(newActiveOperation);
},
@ -287,14 +335,14 @@ export const useOperations = (
);
const deleteArgFromActiveOperation = useCallback(
(path: string, argName: string) => {
(path: string, argName: string, operationName?: string | null) => {
if (!activeOperation?.query) {
return;
}
const newActiveOperation = {
...activeOperation,
query: removeArgFromField(activeOperation.query, path, argName),
query: removeArgFromField(activeOperation.query, path, argName, operationName),
};
updateActiveOperation(newActiveOperation);
},
@ -316,6 +364,8 @@ export const useOperations = (
return activeOperation ? isOperationLoading(activeOperation.id) : false;
}, [activeOperation, isOperationLoading]);
const loader = useMemo(() => new UrlLoader(), []);
const runActiveOperation = useCallback(
async (
endpoint: string,
@ -323,10 +373,11 @@ export const useOperations = (
env?: LaboratoryEnv;
headers?: Record<string, string>;
onResponse?: (response: string) => void;
operationName?: string;
},
plugins: LaboratoryPlugin[] = props.pluginsApi?.plugins ?? [],
pluginsState: Record<string, any> = props.pluginsApi?.pluginsState ?? {},
) => {
): Promise<ExecutionResult | null> => {
if (!activeOperation?.query) {
return null;
}
@ -382,100 +433,91 @@ export const useOperations = (
)
: {};
if (activeOperation.query.startsWith('subscription')) {
const client = createClient({
url: endpoint.replace('http', 'ws'),
connectionParams: {
...mergedHeaders,
const executor = loader.getExecutorAsync(endpoint, {
subscriptionsEndpoint: endpoint,
subscriptionsProtocol:
(props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
SubscriptionProtocol.GRAPHQL_SSE,
credentials: props.settingsApi?.settings.fetch.credentials,
specifiedByUrl: true,
directiveIsRepeatable: true,
inputValueDeprecation: true,
retry: props.settingsApi?.settings.fetch.retry,
timeout: props.settingsApi?.settings.fetch.timeout,
useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries,
exposeHTTPDetailsInExtensions: true,
fetch,
});
const document = getOperationWithFragments(parse(activeOperation.query));
const abortController = new AbortController();
setStopOperationsFunctions(prev => ({
...prev,
[activeOperation.id]: () => {
abortController.abort();
},
}));
try {
const response = await executor({
document,
operationName: options?.operationName,
variables,
extensions: {
...extensions,
headers: mergedHeaders,
},
signal: abortController.signal,
});
client.on('connected', () => {
console.log('connected');
});
client.on('error', () => {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
});
client.on('closed', () => {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
});
client.subscribe(
{
query: activeOperation.query,
variables,
extensions,
},
{
next: message => {
options?.onResponse?.(JSON.stringify(message ?? {}));
},
error: () => {},
complete: () => {},
},
);
setStopOperationsFunctions(prev => ({
...prev,
[activeOperation.id]: () => {
void client.dispose();
if (isAsyncIterable(response)) {
try {
for await (const item of response) {
options?.onResponse?.(JSON.stringify(item ?? {}));
}
} finally {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
},
}));
}
return Promise.resolve(new Response());
}
return null;
}
const abortController = new AbortController();
if (response.extensions?.response?.body) {
delete response.extensions.response.body;
}
const response = fetch(endpoint, {
method: 'POST',
credentials: props.settingsApi?.settings.fetch.credentials,
body: JSON.stringify({
query: activeOperation.query,
variables,
extensions,
}),
headers: {
...mergedHeaders,
'Content-Type': 'application/json',
},
signal: abortController.signal,
}).finally(() => {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
});
setStopOperationsFunctions(prev => ({
...prev,
[activeOperation.id]: () => abortController.abort(),
}));
return response;
} catch (error) {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
return response;
if (error instanceof Error) {
return new GraphQLError(error.message);
}
return new GraphQLError('An unknown error occurred');
}
},
[activeOperation, props.preflightApi, props.envApi, props.pluginsApi],
[activeOperation, props.preflightApi, props.envApi, props.pluginsApi, props.settingsApi],
);
const isOperationSubscription = useCallback((operation: LaboratoryOperation) => {
return operation.query?.startsWith('subscription') ?? false;
return getOperationType(operation.query) === 'subscription';
}, []);
const isActiveOperationSubscription = useMemo(() => {

View file

@ -31,7 +31,7 @@ export function healQuery(query: string) {
return query.replace(/\{(\s+)?\}/g, '');
}
export function isPathInQuery(query: string, path: string, operationName?: string) {
export function isPathInQuery(query: string, path: string, operationName?: string | null) {
if (!query || !path) {
return false;
}
@ -98,7 +98,7 @@ export function isPathInQuery(query: string, path: string, operationName?: strin
return found;
}
export function addPathToQuery(query: string, path: string, operationName?: string) {
export function addPathToQuery(query: string, path: string, operationName?: string | null) {
query = healQuery(query);
const [operation, ...parts] = path.split('.') as [OperationTypeNode, ...string[]];
@ -244,7 +244,7 @@ export function addPathToQuery(query: string, path: string, operationName?: stri
return print(doc);
}
export function deletePathFromQuery(query: string, path: string, operationName?: string) {
export function deletePathFromQuery(query: string, path: string, operationName?: string | null) {
query = healQuery(query);
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
@ -348,8 +348,6 @@ export async function getOperationHash(
operation: Pick<LaboratoryOperation, 'query' | 'variables'>,
) {
try {
console.log(operation.query, operation.variables);
const canonicalQuery = print(parse(operation.query));
const canonicalVariables = '';
const canonical = `${canonicalQuery}\n${canonicalVariables}`;
@ -393,7 +391,12 @@ export function getOperationType(query: string) {
}
}
export function isArgInQuery(query: string, path: string, argName: string, operationName?: string) {
export function isArgInQuery(
query: string,
path: string,
argName: string,
operationName?: string | null,
) {
if (!query || !path) {
return false;
}
@ -527,7 +530,7 @@ export function addArgToField(
path: string,
argName: string,
schema: GraphQLSchema,
operationName?: string,
operationName?: string | null,
) {
query = healQuery(query);
@ -784,7 +787,7 @@ export function removeArgFromField(
query: string,
path: string,
argName: string,
operationName?: string,
operationName?: string | null,
) {
query = healQuery(query);

View file

@ -0,0 +1,225 @@
import { z } from 'zod';
export const FlattenNodePathSegmentSchema = z.union([
z.object({ Field: z.string() }),
z.object({ TypeCondition: z.array(z.string()) }),
z.literal('@'),
]);
export type FlattenNodePathSegment = z.infer<typeof FlattenNodePathSegmentSchema>;
export const FlattenNodePathSchema = z.array(FlattenNodePathSegmentSchema);
export type FlattenNodePath = z.infer<typeof FlattenNodePathSchema>;
export const FlattenNodePathsSchema = z.array(FlattenNodePathSchema);
export type FlattenNodePaths = z.infer<typeof FlattenNodePathsSchema>;
export const FetchNodePathSegmentSchema = z.union([
z.object({ Key: z.string() }),
z.object({ TypenameEquals: z.array(z.string()) }),
]);
export type FetchNodePathSegment = z.infer<typeof FetchNodePathSegmentSchema>;
export const ValueSetterSchema = z.object({
path: z.array(FetchNodePathSegmentSchema),
setValueTo: z.string(),
});
export const KeyRenamerSchema = z.object({
path: z.array(FetchNodePathSegmentSchema),
renameKeyTo: z.string(),
});
export const FetchRewriteSchema = z.union([ValueSetterSchema, KeyRenamerSchema]);
export type FetchRewrite = z.infer<typeof FetchRewriteSchema>;
export const SelectionFieldSchema = z.object({
kind: z.literal('Field'),
name: z.string(),
});
export const SelectionInlineFragmentSchema = z.object({
kind: z.literal('InlineFragment'),
typeCondition: z.string().nullish(),
selections: z.array(SelectionFieldSchema).nullish(),
});
export type SelectionInlineFragment = z.infer<typeof SelectionInlineFragmentSchema>;
export const SelectionFragmentSpreadSchema = z.object({
kind: z.literal('FragmentSpread'),
name: z.string(),
});
export const SelectionItemSchema = z.union([
SelectionInlineFragmentSchema,
SelectionFragmentSpreadSchema,
SelectionFieldSchema,
]);
export type SelectionItem = z.infer<typeof SelectionItemSchema>;
export const SelectionSetSchema = z.array(SelectionItemSchema);
export type SelectionSet = z.infer<typeof SelectionSetSchema>;
export interface SequenceNodePlan {
kind: 'Sequence';
nodes: PlanNode[];
}
export interface ParallelNodePlan {
kind: 'Parallel';
nodes: PlanNode[];
}
export interface FlattenNodePlan {
kind: 'Flatten';
path: FlattenNodePath;
node: PlanNode;
}
export interface ConditionNodePlan {
kind: 'Condition';
condition: string;
ifClause?: PlanNode | null;
elseClause?: PlanNode | null;
}
export interface SubscriptionNodePlan {
kind: 'Subscription';
primary: PlanNode;
}
export interface DeferNodePlan {
kind: 'Defer';
primary: DeferPrimary;
deferred: DeferredNode[];
}
export interface DeferPrimary {
subselection?: string | null;
node?: PlanNode | null;
}
export interface DeferredNode {
depends: DeferDependency[];
label?: string | null;
queryPath: string[];
subselection?: string | null;
node?: PlanNode | null;
}
export type FetchNodePlan = z.infer<typeof FetchNodePlanSchema>;
export type BatchFetchNodePlan = z.infer<typeof BatchFetchNodePlanSchema>;
export type PlanNode =
| FetchNodePlan
| BatchFetchNodePlan
| SequenceNodePlan
| ParallelNodePlan
| FlattenNodePlan
| ConditionNodePlan
| SubscriptionNodePlan
| DeferNodePlan;
export const PlanNodeSchema: z.ZodType<PlanNode> = z.lazy(() =>
z.discriminatedUnion('kind', [
FetchNodePlanSchema,
BatchFetchNodePlanSchema,
SequenceNodePlanSchema,
ParallelNodePlanSchema,
FlattenNodePlanSchema,
ConditionNodePlanSchema,
SubscriptionNodePlanSchema,
DeferNodePlanSchema,
]),
);
export const FetchNodePlanSchema = z.object({
kind: z.literal('Fetch'),
serviceName: z.string(),
operationKind: z.string().nullish(),
operationName: z.string().nullish(),
operation: z.string(),
variableUsages: z.array(z.string()).nullish(),
requires: SelectionSetSchema.nullish(),
inputRewrites: z.array(FetchRewriteSchema).nullish(),
outputRewrites: z.array(FetchRewriteSchema).nullish(),
});
export const EntityBatchAliasSchema = z.object({
alias: z.string(),
representationsVariableName: z.string(),
paths: FlattenNodePathsSchema,
requires: SelectionSetSchema,
inputRewrites: z.array(FetchRewriteSchema).nullish(),
outputRewrites: z.array(FetchRewriteSchema).nullish(),
});
export type EntityBatchAlias = z.infer<typeof EntityBatchAliasSchema>;
export const EntityBatchSchema = z.object({
aliases: z.array(EntityBatchAliasSchema),
});
export type EntityBatch = z.infer<typeof EntityBatchSchema>;
export const BatchFetchNodePlanSchema = z.object({
kind: z.literal('BatchFetch'),
serviceName: z.string(),
operationKind: z.string().nullish(),
operationName: z.string().nullish(),
operation: z.string(),
variableUsages: z.array(z.string()).nullish(),
entityBatch: EntityBatchSchema,
});
export const SequenceNodePlanSchema = z.object({
kind: z.literal('Sequence'),
nodes: z.array(PlanNodeSchema),
});
export const ParallelNodePlanSchema = z.object({
kind: z.literal('Parallel'),
nodes: z.array(PlanNodeSchema),
});
export const FlattenNodePlanSchema = z.object({
kind: z.literal('Flatten'),
path: FlattenNodePathSchema,
node: PlanNodeSchema,
});
export const ConditionNodePlanSchema = z.object({
kind: z.literal('Condition'),
condition: z.string(),
ifClause: PlanNodeSchema.nullish(),
elseClause: PlanNodeSchema.nullish(),
});
export const SubscriptionNodePlanSchema = z.object({
kind: z.literal('Subscription'),
primary: PlanNodeSchema,
});
export const DeferDependencySchema = z.object({
id: z.string(),
deferLabel: z.string().nullish(),
});
export type DeferDependency = z.infer<typeof DeferDependencySchema>;
export const DeferPrimarySchema = z.object({
subselection: z.string().nullish(),
node: PlanNodeSchema.nullish(),
});
export const DeferredNodeSchema = z.object({
depends: z.array(DeferDependencySchema),
label: z.string().nullish(),
queryPath: z.array(z.string()),
subselection: z.string().nullish(),
node: PlanNodeSchema.nullish(),
});
export const DeferNodePlanSchema = z.object({
kind: z.literal('Defer'),
primary: DeferPrimarySchema,
deferred: z.array(DeferredNodeSchema),
});
export const QueryPlanSchema = z.object({
kind: z.literal('QueryPlan'),
node: PlanNodeSchema.nullish(),
});
export type QueryPlan = z.infer<typeof QueryPlanSchema>;

View file

@ -0,0 +1,795 @@
import { useMemo } from 'react';
import { parse, print } from 'graphql';
import { isArray } from 'lodash';
import {
Box,
Boxes,
ClockIcon,
GitForkIcon,
Layers2Icon,
ListOrderedIcon,
LucideProps,
NetworkIcon,
UnlinkIcon,
} from 'lucide-react';
import { v4 as uuidv4 } from 'uuid';
import { Flow, FlowNode } from '@/components/flow';
import { GraphQLIcon } from '@/components/icons';
import { Editor } from '@/components/laboratory/editor';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import {
BatchFetchNodePlan,
ConditionNodePlan,
DeferNodePlan,
FetchNodePlan,
FlattenNodePath,
FlattenNodePathSegment,
FlattenNodePlan,
ParallelNodePlan,
PlanNode,
QueryPlan,
SelectionInlineFragment,
SelectionSet,
SequenceNodePlan,
SubscriptionNodePlan,
} from './schema';
function indent(depth: number): string {
return ' '.repeat(depth);
}
function normalizeStringSet(value: string[] | Set<string> | null | undefined): string[] {
if (!value) return [];
return Array.isArray(value) ? value : Array.from(value);
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function renderFlattenPathSegment(seg: FlattenNodePathSegment): string {
if (seg === '@') return '@';
if (isObject(seg) && 'Field' in seg) {
return String(seg.Field);
}
if (isObject(seg) && 'TypeCondition' in seg) {
const names = normalizeStringSet(seg.TypeCondition as string[] | Set<string>);
return `|[${names.join('|')}]`;
}
if (isArray(seg)) {
return renderFlattenPath(seg);
}
return String(seg);
}
export function renderFlattenPath(path: FlattenNodePath): string {
let out = '';
for (let i = 0; i < path.length; i++) {
const current = path[i];
const next = path[i + 1];
out += renderFlattenPathSegment(current);
if (next !== undefined) {
const nextIsTypeCondition = isObject(next) && 'TypeCondition' in next;
if (!nextIsTypeCondition) out += '.';
}
}
return out;
}
export function renderSelectionSet(
selectionSet: SelectionSet | null | undefined,
depth = 0,
): string {
if (!selectionSet?.length) return '';
const lines: string[] = [];
for (const item of selectionSet) {
if (item.kind === 'InlineFragment') {
lines.push(`${indent(depth)}... on ${item.typeCondition} {`);
for (const property of item.selections ?? []) {
lines.push(`${indent(depth + 1)}${property.name}`);
}
lines.push(`${indent(depth)}}`);
}
}
return lines.join('\n');
}
export function renderQueryPlan(plan: QueryPlan): string {
const lines: string[] = [];
lines.push('QueryPlan {');
if (plan.node) {
lines.push(renderPlanNode(plan.node, 1));
} else {
lines.push(`${indent(1)}None`);
}
lines.push('}');
return lines.join('\n');
}
export function renderPlanNode(node: PlanNode, depth = 0): string {
switch (node.kind) {
case 'Fetch':
return renderFetchNode(node, depth);
case 'BatchFetch':
return renderBatchFetchNode(node, depth);
case 'Flatten':
return renderFlattenNode(node, depth);
case 'Sequence':
return renderSequenceNode(node, depth);
case 'Parallel':
return renderParallelNode(node, depth);
case 'Condition':
return renderConditionNode(node, depth);
case 'Subscription':
return renderSubscriptionNode(node, depth);
case 'Defer':
return renderDeferNode(node, depth);
default:
return `${indent(depth)}<UnknownNode kind="${(node as { kind?: string }).kind ?? 'unknown'}">`;
}
}
export function renderFetchNode(node: FetchNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}Fetch(service: "${node.serviceName}") {`);
if (node.requires) {
lines.push(`${indent(depth + 1)}{`);
const requires = renderSelectionSet(node.requires, depth + 2);
if (requires) lines.push(requires);
lines.push(`${indent(depth + 1)}} =>`);
}
const slice = node.operation.includes('_entities') ? 2 : 1;
try {
lines.push(
`${indent(depth + 1)}{`,
renderMultilineBlock(
print(parse(node.operation))
.split('\n')
.slice(slice, slice * -1)
.join('\n'),
depth + 2 - slice,
),
`${indent(depth + 1)}}`,
);
} catch {
lines.push(`${indent(depth + 1)}${node.operation}`);
}
lines.push(`${indent(depth)}}`);
return lines.join('\n');
}
export function renderBatchFetchNode(node: BatchFetchNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}BatchFetch(service: "${node.serviceName}") {`);
for (let i = 0; i < node.entityBatch.aliases.length; i++) {
const alias = node.entityBatch.aliases[i];
lines.push(
`${indent(depth + 1)}${alias.alias} {`,
`${indent(depth + 2)}paths: [`,
...alias.paths.map(path => `${indent(depth + 3)}"${renderFlattenPath(path)}"`),
`${indent(depth + 2)}]`,
);
const requires = renderSelectionSet(alias.requires, depth + 3);
if (requires) {
lines.push(`${indent(depth + 2)}{`, requires, `${indent(depth + 2)}}`);
}
if (i < node.entityBatch.aliases.length - 1) {
lines.push(`${indent(depth + 1)}}`);
}
}
try {
lines.push(
`${indent(depth + 1)}}`,
`${indent(depth + 1)}{`,
renderMultilineBlock(
print(parse(node.operation)).split('\n').slice(1, -1).join('\n'),
depth + 1,
),
`${indent(depth + 1)}}`,
`${indent(depth)}}`,
);
} catch {
lines.push(`${indent(depth + 1)}${node.operation}`);
}
return lines.join('\n');
}
export function renderFlattenNode(node: FlattenNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(
`${indent(depth)}Flatten(path: "${renderFlattenPath(node.path)}") {`,
renderPlanNode(node.node, depth + 1),
`${indent(depth)}}`,
);
return lines.join('\n');
}
export function renderSequenceNode(node: SequenceNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}Sequence {`);
for (const child of node.nodes) {
lines.push(renderPlanNode(child, depth + 1));
}
lines.push(`${indent(depth)}}`);
return lines.join('\n');
}
export function renderParallelNode(node: ParallelNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}Parallel {`);
for (const child of node.nodes) {
lines.push(renderPlanNode(child, depth + 1));
}
lines.push(`${indent(depth)}}`);
return lines.join('\n');
}
export function renderConditionNode(node: ConditionNodePlan, depth = 0): string {
const lines: string[] = [];
if (node.ifClause && !node.elseClause) {
lines.push(
`${indent(depth)}Include(if: $${node.condition}) {`,
renderPlanNode(node.ifClause, depth + 1),
`${indent(depth)}}`,
);
return lines.join('\n');
}
if (!node.ifClause && node.elseClause) {
lines.push(
`${indent(depth)}Skip(if: $${node.condition}) {`,
renderPlanNode(node.elseClause, depth + 1),
`${indent(depth)}}`,
);
return lines.join('\n');
}
if (node.ifClause && node.elseClause) {
lines.push(
`${indent(depth)}Condition(if: $${node.condition}) {`,
`${indent(depth + 1)}if {`,
renderPlanNode(node.ifClause, depth + 2),
`${indent(depth + 1)}}`,
`${indent(depth + 1)}else {`,
renderPlanNode(node.elseClause, depth + 2),
`${indent(depth + 1)}}`,
`${indent(depth)}}`,
);
return lines.join('\n');
}
return `${indent(depth)}Condition(if: $${node.condition}) {}`;
}
export function renderSubscriptionNode(node: SubscriptionNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(
`${indent(depth)}Subscription {`,
`${indent(depth + 1)}primary {`,
renderPlanNode(node.primary, depth + 2),
`${indent(depth + 1)}}`,
`${indent(depth)}}`,
);
return lines.join('\n');
}
export function renderDeferNode(node: DeferNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}Defer {`, `${indent(depth + 1)}primary {`);
if (node.primary.subselection) {
lines.push(`${indent(depth + 2)}subselection: ${JSON.stringify(node.primary.subselection)}`);
}
if (node.primary.node) {
lines.push(renderPlanNode(node.primary.node, depth + 2));
}
lines.push(`${indent(depth + 1)}}`);
if (node.deferred.length > 0) {
lines.push(`${indent(depth + 1)}deferred {`);
for (const d of node.deferred) {
lines.push(`${indent(depth + 2)}item {`);
if (d.label) lines.push(`${indent(depth + 3)}label: ${JSON.stringify(d.label)}`);
lines.push(`${indent(depth + 3)}queryPath: [${d.queryPath.join(', ')}]`);
if (d.subselection) {
lines.push(`${indent(depth + 3)}subselection: ${JSON.stringify(d.subselection)}`);
}
if (d.depends.length) {
lines.push(
`${indent(depth + 3)}depends: [${d.depends
.map(x => (x.deferLabel ? `${x.id}:${x.deferLabel}` : x.id))
.join(', ')}]`,
);
}
if (d.node) {
lines.push(renderPlanNode(d.node, depth + 3));
}
lines.push(`${indent(depth + 2)}}`);
}
lines.push(`${indent(depth + 1)}}`);
}
lines.push(`${indent(depth)}}`);
return lines.join('\n');
}
function renderMultilineBlock(value: string, depth = 0): string {
return value
.split('\n')
.map(line => `${indent(depth)}${line}`)
.join('\n');
}
export type ExtractedOperation = {
path: string;
nodeKind: 'Fetch' | 'BatchFetch';
serviceName: string;
operationKind?: string | null;
operationName?: string | null;
graphql: string;
};
export function extractOperations(plan: QueryPlan): ExtractedOperation[] {
if (!plan.node) return [];
return extractOperationsFromNode(plan.node, 'root');
}
function extractOperationsFromNode(node: PlanNode, path: string): ExtractedOperation[] {
switch (node.kind) {
case 'Fetch':
return [
{
path,
nodeKind: 'Fetch',
serviceName: node.serviceName,
operationKind: node.operationKind ?? null,
operationName: node.operationName ?? null,
graphql: node.operation,
},
];
case 'BatchFetch':
return [
{
path,
nodeKind: 'BatchFetch',
serviceName: node.serviceName,
operationKind: node.operationKind ?? null,
operationName: node.operationName ?? null,
graphql: node.operation,
},
];
case 'Flatten':
return extractOperationsFromNode(
node.node,
`${path}.flatten(${renderFlattenPath(node.path)})`,
);
case 'Sequence':
return node.nodes.flatMap((child, i) =>
extractOperationsFromNode(child, `${path}.sequence[${i}]`),
);
case 'Parallel':
return node.nodes.flatMap((child, i) =>
extractOperationsFromNode(child, `${path}.parallel[${i}]`),
);
case 'Condition': {
const out: ExtractedOperation[] = [];
if (node.ifClause) {
out.push(...extractOperationsFromNode(node.ifClause, `${path}.if($${node.condition})`));
}
if (node.elseClause) {
out.push(...extractOperationsFromNode(node.elseClause, `${path}.else($${node.condition})`));
}
return out;
}
case 'Subscription':
return extractOperationsFromNode(node.primary, `${path}.subscription.primary`);
case 'Defer': {
const out: ExtractedOperation[] = [];
if (node.primary.node) {
out.push(...extractOperationsFromNode(node.primary.node, `${path}.defer.primary`));
}
node.deferred.forEach((d, i) => {
if (d.node) {
out.push(
...extractOperationsFromNode(
d.node,
`${path}.defer.deferred[${i}]${d.label ? `(${d.label})` : ''}`,
),
);
}
});
return out;
}
default:
return [];
}
}
export interface QueryPlanNode extends FlowNode {
kind: PlanNode['kind'] | 'Root';
children?: QueryPlanNode[];
}
function visitNode(
node: PlanNode,
parentNode: QueryPlanNode | null,
nodes: QueryPlanNode[],
contentPrefix?: React.ReactNode,
detailsContent?: string,
): QueryPlanNode {
let result: QueryPlanNode | null = {
id: uuidv4(),
title: node.kind,
kind: node.kind as QueryPlanNode['kind'],
};
switch (node.kind) {
case 'Fetch':
result.content = () => {
const entity = (node.requires?.[0] as SelectionInlineFragment)?.typeCondition;
return (
<div className="flex flex-col gap-2">
{contentPrefix}
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Service</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{node.serviceName}
</span>
</div>
{entity && (
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Entity</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{entity}
</span>
</div>
)}
<div className="border-t border-dashed">
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="link" className="h-auto w-full pt-2 text-xs">
Show details
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl! max-h-150 h-full w-full">
<DialogHeader>
<DialogTitle>Fetch</DialogTitle>
</DialogHeader>
<div className="h-full overflow-hidden rounded-sm border">
<Editor value={detailsContent ?? renderFetchNode(node)} language="graphql" />
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
};
break;
case 'BatchFetch':
result.headerSuffix = () => {
const totalPaths = node.entityBatch.aliases.reduce(
(acc, alias) => acc + alias.paths.length,
0,
);
return (
<div className="bg-muted flex items-center gap-1 rounded-md p-0.5 pl-1 font-mono text-xs leading-none">
Total paths:
<div className="bg-primary/10 border-primary text-primary rounded-sm border px-1 text-xs font-medium leading-none">
{totalPaths}
</div>
</div>
);
};
result.content = () => {
return (
<div className="*:border-border flex flex-col gap-2 *:border-b *:border-dashed *:pb-2 *:last:border-b-0 *:last:pb-0">
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Service</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{node.serviceName}
</span>
</div>
{node.entityBatch.aliases.map(alias => {
return (
<div key={alias.alias} className="grid gap-2">
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Path</span>
{alias.paths.length > 1 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
<div className="bg-card rounded-sm border px-1 text-xs font-medium">
{alias.paths.length}
</div>
</span>
</TooltipTrigger>
<TooltipContent className="whitespace-pre-wrap font-mono">
{alias.paths.map(path => renderFlattenPath(path)).join(',\n')}
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
{renderFlattenPath(alias.paths[0])}
</span>
</TooltipTrigger>
<TooltipContent>{renderFlattenPath(alias.paths[0])}</TooltipContent>
</Tooltip>
)}
</div>
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Enitity</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{(alias.requires[0] as SelectionInlineFragment).typeCondition}
</span>
</div>
</div>
);
})}
<div className="-mt-2">
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="link" className="h-auto w-full pt-2 text-xs">
Show details
</Button>
</DialogTrigger>
<DialogContent
className="max-w-2xl! max-h-150 h-full w-full"
onMouseDown={e => {
e.preventDefault();
e.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>BatchFetch</DialogTitle>
</DialogHeader>
<div className="h-full overflow-hidden rounded-sm border">
<Editor value={renderBatchFetchNode(node)} language="graphql" />
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
};
break;
case 'Flatten':
result = null;
visitNode(
node.node,
result,
nodes,
<>
{contentPrefix}
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Path</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
{renderFlattenPath(node.path)}
</span>
</TooltipTrigger>
<TooltipContent>{renderFlattenPath(node.path)}</TooltipContent>
</Tooltip>
</div>
</>,
detailsContent ?? renderFlattenNode(node),
);
break;
case 'Sequence': {
result = null;
let prevChild: QueryPlanNode | null = null;
for (let i = 0; i < node.nodes.length; i++) {
const child = node.nodes[i];
const childNode = visitNode(
child,
prevChild,
i === 0 ? [] : nodes,
contentPrefix,
detailsContent,
);
if (i === 0) {
result = childNode;
}
if (prevChild) {
prevChild.next = [childNode.id];
}
prevChild = childNode;
}
break;
}
case 'Parallel':
result.children = [];
for (const child of node.nodes) {
visitNode(child, result, result.children, contentPrefix, detailsContent);
}
break;
case 'Condition':
result = null;
if (node.ifClause) {
visitNode(
node.ifClause,
result,
nodes,
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Include</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
if: ${node.condition}
</span>
</div>,
renderConditionNode(node),
);
}
if (node.elseClause) {
visitNode(
node.elseClause,
result,
nodes,
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Skip</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
if: ${node.condition}
</span>
</div>,
renderConditionNode(node),
);
}
break;
case 'Subscription':
visitNode(node.primary, result, nodes, contentPrefix, detailsContent);
break;
case 'Defer':
if (node.primary.node) {
visitNode(node.primary.node, result, nodes, contentPrefix, detailsContent);
}
for (const deferred of node.deferred) {
if (deferred.node) {
visitNode(deferred.node, result, nodes, contentPrefix, detailsContent);
}
}
break;
default:
break;
}
if (parentNode && result) {
parentNode.next = [...(parentNode.next ?? []), result.id!];
}
if (result) {
result.icon = queryPlanNodeIcon(result.kind);
nodes.push(result as QueryPlanNode);
}
return result as QueryPlanNode;
}
export const queryPlanNodeIcon = (
kind: QueryPlanNode['kind'],
): ((props: LucideProps) => React.ReactNode) => {
return (props: LucideProps) => {
switch (kind) {
case 'Root':
return (
<GraphQLIcon {...props} className={cn(props.className, 'size-6 min-w-6 text-pink-500')} />
);
case 'Fetch':
return <Box {...props} />;
case 'BatchFetch':
return <Boxes {...props} />;
case 'Flatten':
return <Layers2Icon {...props} />;
case 'Sequence':
return <ListOrderedIcon {...props} />;
case 'Parallel':
return <NetworkIcon {...props} />;
case 'Condition':
return <GitForkIcon {...props} className={cn('rotate-90', props.className)} />;
case 'Subscription':
return <UnlinkIcon {...props} />;
case 'Defer':
return <ClockIcon {...props} />;
}
};
};
export function QueryPlanTree(props: { plan: QueryPlan }) {
const nodes = useMemo(() => {
const nodes: QueryPlanNode[] = [];
const rootNode: QueryPlanNode = {
id: uuidv4(),
title: '',
kind: 'Root',
maxWidth: 42,
};
nodes.push(rootNode);
if (props.plan.node) {
visitNode(props.plan.node, rootNode, nodes);
}
return nodes.map(node => {
return {
...node,
icon: queryPlanNodeIcon(node.kind),
} satisfies FlowNode;
});
}, [props.plan]);
return <Flow nodes={nodes} />;
}

View file

@ -3,9 +3,56 @@ import { useCallback, useState } from 'react';
export type LaboratorySettings = {
fetch: {
credentials: 'include' | 'omit' | 'same-origin';
timeout?: number;
retry?: number;
useGETForQueries?: boolean;
};
subscriptions: {
protocol: 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS';
};
introspection: {
method?: 'GET' | 'POST';
schemaDescription?: boolean;
};
};
export const defaultLaboratorySettings: LaboratorySettings = {
fetch: {
credentials: 'same-origin',
timeout: 10000,
retry: 3,
useGETForQueries: false,
},
subscriptions: {
protocol: 'WS',
},
introspection: {
method: 'POST',
schemaDescription: false,
},
};
export const normalizeLaboratorySettings = (
settings?: Partial<LaboratorySettings> | null,
): LaboratorySettings => ({
fetch: {
credentials: settings?.fetch?.credentials ?? defaultLaboratorySettings.fetch.credentials,
timeout: settings?.fetch?.timeout ?? defaultLaboratorySettings.fetch.timeout,
retry: settings?.fetch?.retry ?? defaultLaboratorySettings.fetch.retry,
useGETForQueries:
settings?.fetch?.useGETForQueries ?? defaultLaboratorySettings.fetch.useGETForQueries,
},
subscriptions: {
protocol: settings?.subscriptions?.protocol ?? defaultLaboratorySettings.subscriptions.protocol,
},
introspection: {
method: settings?.introspection?.method ?? defaultLaboratorySettings.introspection.method,
schemaDescription:
settings?.introspection?.schemaDescription ??
defaultLaboratorySettings.introspection.schemaDescription,
},
});
export interface LaboratorySettingsState {
settings: LaboratorySettings;
}
@ -19,17 +66,14 @@ export const useSettings = (props: {
onSettingsChange?: (settings: LaboratorySettings | null) => void;
}): LaboratorySettingsState & LaboratorySettingsActions => {
const [settings, _setSettings] = useState<LaboratorySettings>(
props.defaultSettings ?? {
fetch: {
credentials: 'same-origin',
},
},
normalizeLaboratorySettings(props.defaultSettings),
);
const setSettings = useCallback(
(settings: LaboratorySettings) => {
_setSettings(settings);
props.onSettingsChange?.(settings);
const normalizedSettings = normalizeLaboratorySettings(settings);
_setSettings(normalizedSettings);
props.onSettingsChange?.(normalizedSettings);
},
[props],
);

View file

@ -17,3 +17,30 @@ export function splitIdentifier(input: string): string[] {
.split(/\s+/)
.map(w => w.toLowerCase());
}
export async function asyncInterval(
fn: () => Promise<void>,
delay: number,
signal?: AbortSignal,
): Promise<void> {
while (!signal?.aborted) {
await fn();
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true },
);
});
}
}
export function isAsyncIterable<T>(val: unknown): val is AsyncIterable<T> {
return typeof Object(val)[Symbol.asyncIterator] === 'function';
}

View file

@ -53,7 +53,7 @@
},
"devDependencies": {
"tslib": "2.8.1",
"vitest": "4.0.9"
"vitest": "4.1.3"
},
"sideEffects": false,
"typescript": {

Some files were not shown because too many files have changed in this diff Show more