Compare commits

...

19 commits

Author SHA1 Message Date
Saihajpreet Singh
19d3822973
fix: set Redis maxmemory eviction policy to allkeys-lru (#8063)
Some checks failed
ci / package (push) Has been cancelled
ci / code-style (push) Has been cancelled
ci / typescript (push) Has been cancelled
ci / graphql-schema (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
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:05:37 +03:00
TheGuildBot
cb68b50534
Upcoming Release Changes (#8048)
Some checks failed
ci / package (push) Has been cancelled
ci / code-style (push) Has been cancelled
ci / typescript (push) Has been cancelled
ci / graphql-schema (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
2026-05-15 13:35:08 -07:00
Michael Skorokhodov
0e3ce40070
lab to append active operation headers to introspection header (#8024)
Some checks are pending
ci / package (push) Waiting to run
ci / build (push) Blocked by required conditions
ci / publish_docker_cli (push) Blocked by required conditions
ci / trigger staging deployment (push) Blocked by required conditions
ci / code-style (push) Waiting to run
ci / typescript (push) Waiting to run
ci / graphql-schema (push) Waiting to run
2026-05-14 12:03:08 -07:00
jdolle
aa38edc106
fix: dangerous changes docs link in target settings (#8047)
Some checks are pending
ci / package (push) Waiting to run
ci / build (push) Blocked by required conditions
ci / publish_docker_cli (push) Blocked by required conditions
ci / trigger staging deployment (push) Blocked by required conditions
ci / code-style (push) Waiting to run
ci / typescript (push) Waiting to run
ci / graphql-schema (push) Waiting to run
2026-05-13 16:31:43 +02:00
Laurin
3fad965894
chore: add minimum release age config + bump pnpm to latest 10.x.x version (#8046)
Some checks are pending
ci / package (push) Waiting to run
ci / build (push) Blocked by required conditions
ci / publish_docker_cli (push) Blocked by required conditions
ci / trigger staging deployment (push) Blocked by required conditions
ci / code-style (push) Waiting to run
ci / typescript (push) Waiting to run
ci / graphql-schema (push) Waiting to run
2026-05-13 10:03:28 +02:00
Laurin
ce4d445f3c
chore: vulnerabilities 2026-05-12 part 2 (#8044) 2026-05-13 08:50:10 +02:00
TheGuildBot
24652f0847
Upcoming Release Changes (#8022)
Some checks are pending
ci / package (push) Waiting to run
ci / build (push) Blocked by required conditions
ci / publish_docker_cli (push) Blocked by required conditions
ci / trigger staging deployment (push) Blocked by required conditions
ci / code-style (push) Waiting to run
ci / typescript (push) Waiting to run
ci / graphql-schema (push) Waiting to run
2026-05-12 17:47:24 +02:00
Laurin
a3ba6ccaed
fix: app deployment publish permissions (#8042) 2026-05-12 14:52:26 +02:00
Vojtěch Dohnal
63e682791f
feat: add configurable server listen address (#7982)
Some checks are pending
ci / package (push) Waiting to run
ci / build (push) Blocked by required conditions
ci / publish_docker_cli (push) Blocked by required conditions
ci / trigger staging deployment (push) Blocked by required conditions
ci / code-style (push) Waiting to run
ci / typescript (push) Waiting to run
ci / graphql-schema (push) Waiting to run
Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
2026-05-12 12:00:11 +02:00
Laurin
931c3274bf
bump otel dependencies (#8040) 2026-05-12 10:41:55 +02:00
Laurin
0b091375b5
chore: move all schema version crud logic from legacy storage module to dedicated class (#8029)
Some checks are pending
ci / package (push) Waiting to run
ci / build (push) Blocked by required conditions
ci / publish_docker_cli (push) Blocked by required conditions
ci / trigger staging deployment (push) Blocked by required conditions
ci / code-style (push) Waiting to run
ci / typescript (push) Waiting to run
ci / graphql-schema (push) Waiting to run
2026-05-11 22:59:29 +02:00
Laurin
9dc61ac48b
fix: ts error (#8038) 2026-05-11 22:27:34 +02:00
Laurin
fee9fed0a1
chore: remove unused property (#8032)
Some checks are pending
ci / publish_docker_cli (push) Blocked by required conditions
ci / trigger staging deployment (push) Blocked by required conditions
ci / package (push) Waiting to run
ci / build (push) Blocked by required conditions
ci / code-style (push) Waiting to run
ci / typescript (push) Waiting to run
ci / graphql-schema (push) Waiting to run
2026-05-11 18:40:17 +02:00
Laurin
0cd6cc5606
chore: vulnerabilities 2026-05-11 (#8035) 2026-05-11 17:20:57 +02:00
Laurin
67ee116a20
fix: conditional breaking changes link to docs (#8034) 2026-05-11 16:59:37 +02:00
Laurin
c98625c47b
chore: some small dev docs adjustments (#8025)
Some checks failed
ci / build (push) Has been cancelled
ci / publish_docker_cli (push) Has been cancelled
ci / trigger staging deployment (push) Has been cancelled
ci / graphql-schema (push) Has been cancelled
ci / package (push) Has been cancelled
ci / code-style (push) Has been cancelled
ci / typescript (push) Has been cancelled
2026-05-06 16:52:29 +02:00
Laurin
80b76004da
feat: write app deployment manifest to CDN (#8015)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-06 16:52:06 +02:00
jdolle
a989647908
fix: OIDC verification domains are now inserted and compared against lower… (#8020) 2026-05-06 09:07:26 +02:00
jdolle
51e5baa0dd
Upgrade graphql-inspector to flag defaulted inputs as dangerous (#8021)
Some checks failed
ci / package (push) Has been cancelled
ci / code-style (push) Has been cancelled
ci / typescript (push) Has been cancelled
ci / graphql-schema (push) Has been cancelled
ci / publish_docker_cli (push) Has been cancelled
ci / build (push) Has been cancelled
ci / trigger staging deployment (push) Has been cancelled
2026-04-30 17:00:24 -07:00
105 changed files with 4788 additions and 3671 deletions

View file

@ -0,0 +1,5 @@
---
'hive': patch
---
add eviction policy to redis

View file

@ -80,3 +80,56 @@ We use changesets for versioning.
- These changesets should have the right amount of informational content for someone to understand - These changesets should have the right amount of informational content for someone to understand
what this change introduces for their self-hosted Hive instance, without going into too much what this change introduces for their self-hosted Hive instance, without going into too much
internal technical detail. internal technical detail.
---
## Pull Request Reviewing
### What to Look For
Code that looks wrong in isolation may be correct given surrounding logic—and vice versa. Read the
full file to understand existing patterns, control flow, and error handling.
**Bugs** - Your primary focus.
- Logic errors, off-by-one mistakes, incorrect conditionals
- If-else guards: missing guards, incorrect branching, unreachable code paths
- Edge cases: null/empty/undefined inputs, error conditions, race conditions
- Security issues: injection, auth bypass, data exposure
- Broken error handling that swallows failures, throws unexpectedly or returns error types that are
not caught.
**Structure** - Does the code fit the codebase?
- Does it follow existing patterns and conventions?
- Are there established abstractions it should use but doesn't?
- Excessive nesting that could be flattened with early returns or extraction
**Performance** - Only flag if obviously problematic.
- O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths
**Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly
unintentional).
---
### Before You Flag Something
**Be certain.** If you're going to call something a bug, you need to be confident it actually is
one.
- Only review the changes - do not review pre-existing code that wasn't modified
- Don't flag something as a bug if you're unsure - investigate first
- Don't invent hypothetical problems - if an edge case matters, explain the realistic scenario where
it breaks
- If you need more context to be sure, use the tools below to get it
**Don't be a zealot about style.** When checking code against conventions:
- Verify the code is _actually_ in violation. Don't complain about else statements if early returns
are already being used correctly.
- Some "violations" are acceptable when they're the simplest option.
- Excessive nesting is a legitimate concern regardless of other style choices.
- Don't flag style preferences as issues unless they clearly violate established project
conventions.

View file

@ -1,5 +1,87 @@
# hive # hive
## 11.1.1
### Patch Changes
- [#8044](https://github.com/graphql-hive/console/pull/8044)
[`ce4d445`](https://github.com/graphql-hive/console/commit/ce4d445f3c2a2d214b7c47e35c9a38f4de7c3f0e)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-q6x5-8v7m-xcrf](https://github.com/advisories/GHSA-q6x5-8v7m-xcrf),
[GHSA-jvwf-75h9-cwgg](https://github.com/advisories/GHSA-jvwf-75h9-cwgg),
[GHSA-75px-5xx7-5xc7](https://github.com/advisories/GHSA-75px-5xx7-5xc7),
[GHSA-66ff-xgx4-vchm](https://github.com/advisories/GHSA-66ff-xgx4-vchm),
[GHSA-685m-2w69-288q](https://github.com/advisories/GHSA-685m-2w69-288q),
[GHSA-2pr8-phx7-x9h3](https://github.com/advisories/GHSA-2pr8-phx7-x9h3) and
[GHSA-fx83-v9x8-x52w](https://github.com/advisories/GHSA-fx83-v9x8-x52w).
## 11.1.0
### Minor Changes
- [#8015](https://github.com/graphql-hive/console/pull/8015)
[`80b7600`](https://github.com/graphql-hive/console/commit/80b76004da822f9f526fef3160b51c834865d266)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Write app deployment manifest to CDN upon app
activation and retirement. The app deployment manifest can be used to discover what hashes belong
to an app deployment version.
- [#7982](https://github.com/graphql-hive/console/pull/7982)
[`63e6827`](https://github.com/graphql-hive/console/commit/63e682791fbb3c53f05e34329043c8fc8b705189)
Thanks [@jetocotoje](https://github.com/jetocotoje)! - Add configuration for specifying services
listening host. It is now possible to specify on which host the services are listening.
Furthermore, the services can be configured to only listing on IPv6.
The behaviour can be configrued via the two new environment variables `SERVER_HOST` and
`SERVER_HOST_IPV6_ONLY` for each service.
```
SERVER_HOST="::"
SERVER_HOST_IPV6_ONLY="0"
```
### Patch Changes
- [#8020](https://github.com/graphql-hive/console/pull/8020)
[`a989647`](https://github.com/graphql-hive/console/commit/a989647908ad7635831d7bd753076fb12f98dbe8)
Thanks [@jdolle](https://github.com/jdolle)! - OIDC verification domains are now inserted and
compared against lowercase domains. Any existing domains that use an uppercase letter must be
converted to lowercase. This is to avoid an unnecessary convert to LOWER() in the SQL statements.
To convert existing domains, run the SQL query:
```
UPDATE oidc_integration_domains
SET domain_name=LOWER(domain_name)
WHERE domain_name != LOWER(domain_name)
;
```
- [#8040](https://github.com/graphql-hive/console/pull/8040)
[`931c327`](https://github.com/graphql-hive/console/commit/931c3274bf69984d00a3db3e6e20adcfcd39ad4b)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerabilityies
[GHSA-q7rr-3cgh-j5r3](https://github.com/advisories/GHSA-q7rr-3cgh-j5r3).
- [#8035](https://github.com/graphql-hive/console/pull/8035)
[`0cd6cc5`](https://github.com/graphql-hive/console/commit/0cd6cc5606e8cf3c952583feec956c8f024ee615)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-pf86-5x62-jrwf](https://github.com/advisories/GHSA-pf86-5x62-jrwf)
- [#8021](https://github.com/graphql-hive/console/pull/8021)
[`51e5baa`](https://github.com/graphql-hive/console/commit/51e5baa0dd42b9a5fcd499e60f03baa0c45c8da9)
Thanks [@jdolle](https://github.com/jdolle)! - "INPUT_FIELD_ADDED" is now classified as Dangerous
(was NonBreaking) when the added field has a default value, since rolling deploys can expose
consumers to the default before producers are ready.
- [#8035](https://github.com/graphql-hive/console/pull/8035)
[`0cd6cc5`](https://github.com/graphql-hive/console/commit/0cd6cc5606e8cf3c952583feec956c8f024ee615)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-5wm8-gmm8-39j9](https://github.com/advisories/GHSA-5wm8-gmm8-39j9)
- [#8035](https://github.com/graphql-hive/console/pull/8035)
[`0cd6cc5`](https://github.com/graphql-hive/console/commit/0cd6cc5606e8cf3c952583feec956c8f024ee615)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-q3j6-qgpj-74h6](https://github.com/advisories/GHSA-q3j6-qgpj-74h6)
## 11.0.4 ## 11.0.4
### Patch Changes ### Patch Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "hive", "name": "hive",
"version": "11.0.4", "version": "11.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"generate": "tsx generate.ts", "generate": "tsx generate.ts",
@ -22,7 +22,7 @@
"prettier": "3.4.2" "prettier": "3.4.2"
}, },
"devDependencies": { "devDependencies": {
"@graphql-hive/gateway": "^2.1.19", "@graphql-hive/gateway": "2.7.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/node": "24.12.2", "@types/node": "24.12.2",

View file

@ -120,6 +120,12 @@ export class Redis {
args: [ args: [
'/opt/bitnami/scripts/redis/run.sh', '/opt/bitnami/scripts/redis/run.sh',
`--maxmemory ${memoryInMegabytes}mb`, `--maxmemory ${memoryInMegabytes}mb`,
// Once Redis reaches maxmemory (90% of the container limit, see above),
// evict the least-recently-used keys instead of failing writes with OOM
// errors (the default `noeviction` behaviour). Everything Hive stores in
// this Redis is a cache / lock / rate-limit entry with a TTL, so evicting
// cold keys is safe and LRU keeps hot keys (locks, fresh counters) around.
'--maxmemory-policy allkeys-lru',
// This disables snapshotting to save cpu and reduce latency spikes // This disables snapshotting to save cpu and reduce latency spikes
'--save ""', '--save ""',
], ],

View file

@ -203,6 +203,8 @@ services:
ENCRYPTION_SECRET: '${HIVE_ENCRYPTION_SECRET}' ENCRYPTION_SECRET: '${HIVE_ENCRYPTION_SECRET}'
WEB_APP_URL: '${HIVE_APP_BASE_URL}' WEB_APP_URL: '${HIVE_APP_BASE_URL}'
PORT: 3001 PORT: 3001
SERVER_HOST: '${SERVER_HOST:-::}'
SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}'
S3_ENDPOINT: 'http://s3:9000' S3_ENDPOINT: 'http://s3:9000'
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER} S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
@ -234,6 +236,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3012 PORT: 3012
SERVER_HOST: '${SERVER_HOST:-::}'
SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}'
LOG_LEVEL: '${LOG_LEVEL:-debug}' LOG_LEVEL: '${LOG_LEVEL:-debug}'
OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}' OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}'
SENTRY: '${SENTRY:-0}' SENTRY: '${SENTRY:-0}'
@ -250,6 +254,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3002 PORT: 3002
SERVER_HOST: '${SERVER_HOST:-::}'
SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}'
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_PASSWORD: '${REDIS_PASSWORD}' REDIS_PASSWORD: '${REDIS_PASSWORD}'
@ -278,6 +284,8 @@ services:
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_PASSWORD: '${REDIS_PASSWORD}' REDIS_PASSWORD: '${REDIS_PASSWORD}'
PORT: 3003 PORT: 3003
SERVER_HOST: '${SERVER_HOST:-::}'
SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}'
LOG_LEVEL: '${LOG_LEVEL:-debug}' LOG_LEVEL: '${LOG_LEVEL:-debug}'
OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}' OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}'
SENTRY: '${SENTRY:-0}' SENTRY: '${SENTRY:-0}'
@ -294,6 +302,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3014 PORT: 3014
SERVER_HOST: '${SERVER_HOST:-::}'
SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}'
POSTGRES_HOST: db POSTGRES_HOST: db
POSTGRES_PORT: 5432 POSTGRES_PORT: 5432
POSTGRES_DB: '${POSTGRES_DB}' POSTGRES_DB: '${POSTGRES_DB}'
@ -340,6 +350,8 @@ services:
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_PASSWORD: '${REDIS_PASSWORD}' REDIS_PASSWORD: '${REDIS_PASSWORD}'
PORT: 3006 PORT: 3006
SERVER_HOST: '${SERVER_HOST:-::}'
SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}'
LOG_LEVEL: '${LOG_LEVEL:-debug}' LOG_LEVEL: '${LOG_LEVEL:-debug}'
SENTRY: '${SENTRY:-0}' SENTRY: '${SENTRY:-0}'
SENTRY_DSN: '${SENTRY_DSN:-}' SENTRY_DSN: '${SENTRY_DSN:-}'
@ -367,6 +379,8 @@ services:
CLICKHOUSE_USERNAME: '${CLICKHOUSE_USER}' CLICKHOUSE_USERNAME: '${CLICKHOUSE_USER}'
CLICKHOUSE_PASSWORD: '${CLICKHOUSE_PASSWORD}' CLICKHOUSE_PASSWORD: '${CLICKHOUSE_PASSWORD}'
PORT: 3007 PORT: 3007
SERVER_HOST: '${SERVER_HOST:-::}'
SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}'
LOG_LEVEL: '${LOG_LEVEL:-debug}' LOG_LEVEL: '${LOG_LEVEL:-debug}'
SENTRY: '${SENTRY:-0}' SENTRY: '${SENTRY:-0}'
SENTRY_DSN: '${SENTRY_DSN:-}' SENTRY_DSN: '${SENTRY_DSN:-}'
@ -380,6 +394,8 @@ services:
- 'stack' - 'stack'
environment: environment:
PORT: 3000 PORT: 3000
SERVER_HOST: '${SERVER_HOST:-::}'
SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}'
NODE_ENV: production NODE_ENV: production
APP_BASE_URL: '${HIVE_APP_BASE_URL}' APP_BASE_URL: '${HIVE_APP_BASE_URL}'
GRAPHQL_PUBLIC_ENDPOINT: http://localhost:8082/graphql GRAPHQL_PUBLIC_ENDPOINT: http://localhost:8082/graphql

View file

@ -4,8 +4,8 @@
Developing Hive locally requires you to have the following software installed locally: Developing Hive locally requires you to have the following software installed locally:
- Node.js >=22 (or `nvm` or `fnm`) - Node.js (or `nvm` or `fnm`): check the `package.json` `engines` entry for the correct version
- pnpm >=10.16.0 - pnpm: check the `package.json` `engines` entry for the correct version
- Docker version 26.1.1 or later(previous versions will not work correctly on arm64) - Docker version 26.1.1 or later(previous versions will not work correctly on arm64)
- make sure these ports are free: 5432, 6379, 9000, 9001, 8123, 9092, 8081, 8082, 9644, 3567, 7043 - make sure these ports are free: 5432, 6379, 9000, 9001, 8123, 9092, 8081, 8082, 9644, 3567, 7043
@ -13,7 +13,7 @@ Developing Hive locally requires you to have the following software installed lo
- Clone the repository locally - Clone the repository locally
- Make sure to install the recommended VSCode extensions (defined in `.vscode/extensions.json`) - Make sure to install the recommended VSCode extensions (defined in `.vscode/extensions.json`)
- In the root of the repo, run `nvm use` to use the same version of node as mentioned above - In the root of the repo, run `nvm use` (or `fnm use`) to use the required Node.js version
- Create `.env` file in the root, and use the following: - Create `.env` file in the root, and use the following:
```dotenv ```dotenv
@ -42,7 +42,8 @@ Add "user" field to ./docker/docker-compose.dev.yml
- Run `pnpm generate` to generate the typings from the graphql files (use `pnpm graphql:generate` if - Run `pnpm generate` to generate the typings from the graphql files (use `pnpm graphql:generate` if
you only need to run GraphQL Codegen) you only need to run GraphQL Codegen)
- Run `pnpm build` to build all services - Run `pnpm build` to build all services
- Click on `Start Hive` in the bottom bar of VSCode - Click on `Start Hive` in the bottom bar of VSCode (alternatively you can manually start the
services you need)
- Open the UI (`http://localhost:3000` by default) and Sign in with any of the identity provider - Open the UI (`http://localhost:3000` by default) and Sign in with any of the identity provider
- Once this is done, you should be able to log in and use the project - Once this is done, you should be able to log in and use the project

View file

@ -8,6 +8,7 @@ import { createHive } from '@graphql-hive/core';
import { psql } from '@hive/postgres'; import { psql } from '@hive/postgres';
import { clickHouseInsert } from '../../testkit/clickhouse'; import { clickHouseInsert } from '../../testkit/clickhouse';
import { graphql } from '../../testkit/gql'; import { graphql } from '../../testkit/gql';
import { ResourceAssignmentModeType } from '../../testkit/gql/graphql';
import { execute } from '../../testkit/graphql'; import { execute } from '../../testkit/graphql';
const CreateAppDeployment = graphql(` const CreateAppDeployment = graphql(`
@ -1742,8 +1743,9 @@ test('retire app deployments fails without feature flag enabled for organization
}); });
test('get app deployment documents via GraphQL API', async () => { test('get app deployment documents via GraphQL API', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner(); const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg(); const { createProject, setFeatureFlag, organization, createOrganizationAccessToken } =
await createOrg();
await setFeatureFlag('appDeployments', true); await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject(); const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({}); const token = await createTargetAccessToken({});
@ -1771,12 +1773,23 @@ test('get app deployment documents via GraphQL API', async () => {
`, `,
}); });
// Ensure this is possible with the minimal available permissions.
const organizationAccessToken = await createOrganizationAccessToken({
permissions: ['appDeployment:create'],
resources: {
mode: ResourceAssignmentModeType.All,
},
});
const { addDocumentsToAppDeployment } = await execute({ const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment, document: AddDocumentsToAppDeployment,
variables: { variables: {
input: { input: {
appName: 'app-name', appName: 'app-name',
appVersion: 'app-version', appVersion: 'app-version',
target: {
byId: target.id,
},
documents: [ documents: [
{ {
hash: 'aaa', hash: 'aaa',
@ -1797,7 +1810,7 @@ test('get app deployment documents via GraphQL API', async () => {
], ],
}, },
}, },
authToken: token.secret, authToken: organizationAccessToken.privateAccessKey,
}).then(res => res.expectNoGraphQLErrors()); }).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull(); expect(addDocumentsToAppDeployment.error).toBeNull();
@ -1812,7 +1825,7 @@ test('get app deployment documents via GraphQL API', async () => {
appDeploymentName: 'app-name', appDeploymentName: 'app-name',
appDeploymentVersion: 'app-version', appDeploymentVersion: 'app-version',
}, },
authToken: ownerToken, authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors()); }).then(res => res.expectNoGraphQLErrors());
expect(result.target).toMatchObject({ expect(result.target).toMatchObject({
appDeployment: { appDeployment: {
@ -2157,6 +2170,137 @@ test('app deployment usage reporting', async () => {
expect(data.target?.appDeployment?.lastUsed).toEqual(expect.any(String)); expect(data.target?.appDeployment?.lastUsed).toEqual(expect.any(String));
}); });
test('app deployment manifest is written to and accessible via CDN', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, createCdnAccess } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment.error).toBeNull();
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
a: String
b: String
c: String
d: String
}
`,
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
documents: [
{
hash: 'aaa',
body: 'query { a }',
},
{
hash: 'bbb',
body: 'query { b }',
},
{
hash: 'ccc',
body: 'query { c }',
},
{
hash: 'ddd',
body: 'query { d }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
const cdnAccess = await createCdnAccess();
const persistedOperationUrl = `${cdnAccess.cdnUrl}/apps/app-name/app-version`;
let response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
// before the app deployment is activated it shall not exist.
expect(response.status).toEqual(404);
const { activateAppDeployment } = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateAppDeployment.error).toBeNull();
response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
expect(response.status).toEqual(200);
let manifest = await response.json();
expect(manifest).toMatchObject({
appName: 'app-name',
appVersion: 'app-version',
documentHashes: ['aaa', 'bbb', 'ccc', 'ddd'],
id: expect.any(String),
isActive: true,
});
// Retire flow.
const { retireAppDeployment } = await execute({
document: RetireAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireAppDeployment.error).toBeNull();
response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
expect(response.status).toEqual(200);
manifest = await response.json();
expect(manifest).toMatchObject({
appName: 'app-name',
appVersion: 'app-version',
documentHashes: ['aaa', 'bbb', 'ccc', 'ddd'],
id: expect.any(String),
isActive: false,
});
});
test('activeAppDeployments returns empty list when no active deployments exist', async () => { test('activeAppDeployments returns empty list when no active deployments exist', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner(); const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg(); const { createProject, setFeatureFlag, organization } = await createOrg();

View file

@ -3,6 +3,7 @@ import {
ResourceAssignmentModeType, ResourceAssignmentModeType,
RuleInstanceSeverityLevel, RuleInstanceSeverityLevel,
} from 'testkit/gql/graphql'; } from 'testkit/gql/graphql';
import { SchemaVersionStore } from '@hive/api/modules/schema/providers/schema-version-store';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { createStorage } from '@hive/storage'; import { createStorage } from '@hive/storage';
import { graphql } from '../../../testkit/gql'; import { graphql } from '../../../testkit/gql';
@ -2103,7 +2104,8 @@ test.concurrent(
const conn = connectionString(); const conn = connectionString();
const storage = await createStorage(conn, 2); const storage = await createStorage(conn, 2);
await storage.createVersion({ const schemaVersions = new SchemaVersionStore(storage.pool);
await schemaVersions.createSchemaVersion({
schema: brokenSdl, schema: brokenSdl,
author: 'Jochen', author: 'Jochen',
async actionFn() {}, async actionFn() {},
@ -2113,7 +2115,6 @@ test.concurrent(
compositeSchemaSDL: null, compositeSchemaSDL: null,
conditionalBreakingChangeMetadata: null, conditionalBreakingChangeMetadata: null,
contracts: null, contracts: null,
coordinatesDiff: null,
diffSchemaVersionId: null, diffSchemaVersionId: null,
github: null, github: null,
metadata: null, metadata: null,

View file

@ -5,6 +5,7 @@ import { ProjectType } from 'testkit/gql/graphql';
import { initSeed } from 'testkit/seed'; import { initSeed } from 'testkit/seed';
import { assertNonNull, getServiceHost } from 'testkit/utils'; import { assertNonNull, getServiceHost } from 'testkit/utils';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { SchemaVersionStore } from '@hive/api/modules/schema/providers/schema-version-store';
import { createStorage } from '@hive/storage'; import { createStorage } from '@hive/storage';
import { sortSDL } from '@theguild/federation-composition'; import { sortSDL } from '@theguild/federation-composition';
@ -132,6 +133,7 @@ test.concurrent(
try { try {
storage = await createStorage(connectionString(), 1); storage = await createStorage(connectionString(), 1);
const schemaVersions = new SchemaVersionStore(storage.pool);
const { createOrg } = await initSeed().createOwner(); const { createOrg } = await initSeed().createOwner();
const { createProject, organization } = await createOrg(); const { createProject, organization } = await createOrg();
const { createTargetAccessToken, project, target } = await createProject( const { createTargetAccessToken, project, target } = await createProject(
@ -171,11 +173,7 @@ test.concurrent(
.then(r => r.expectNoGraphQLErrors()); .then(r => r.expectNoGraphQLErrors());
expect(deleteServiceResult.schemaDelete.__typename).toBe('SchemaDeleteSuccess'); expect(deleteServiceResult.schemaDelete.__typename).toBe('SchemaDeleteSuccess');
const latestVersion = await storage.getMaybeLatestVersion({ const latestVersion = await schemaVersions.getMaybeLatestSchemaVersionForTargetId(target.id);
targetId: target.id,
projectId: project.id,
organizationId: organization.id,
});
assertNonNull(latestVersion); assertNonNull(latestVersion);
expect(latestVersion.compositeSchemaSDL).toMatchInlineSnapshot(` expect(latestVersion.compositeSchemaSDL).toMatchInlineSnapshot(`
@ -187,9 +185,7 @@ test.concurrent(
expect(latestVersion.hasPersistedSchemaChanges).toEqual(true); expect(latestVersion.hasPersistedSchemaChanges).toEqual(true);
expect(latestVersion.isComposable).toEqual(true); expect(latestVersion.isComposable).toEqual(true);
const changes = await storage.getSchemaChangesForVersion({ const changes = await schemaVersions.getSchemaSchangesForSchemaVersion(latestVersion);
versionId: latestVersion.id,
});
if (Array.isArray(changes) === false) { if (Array.isArray(changes) === false) {
throw new Error('No changes were persisted in the database.'); throw new Error('No changes were persisted in the database.');
@ -229,6 +225,7 @@ test.concurrent(
try { try {
storage = await createStorage(connectionString(), 1); storage = await createStorage(connectionString(), 1);
const schemaVersions = new SchemaVersionStore(storage.pool);
const { createOrg, ownerToken } = await initSeed().createOwner(); const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, organization } = await createOrg(); const { createProject, organization } = await createOrg();
const { createTargetAccessToken, project, target, setNativeFederation } = await createProject( const { createTargetAccessToken, project, target, setNativeFederation } = await createProject(
@ -311,11 +308,7 @@ test.concurrent(
.then(r => r.expectNoGraphQLErrors()); .then(r => r.expectNoGraphQLErrors());
expect(deleteServiceResult.schemaDelete.__typename).toBe('SchemaDeleteSuccess'); expect(deleteServiceResult.schemaDelete.__typename).toBe('SchemaDeleteSuccess');
const latestVersion = await storage.getMaybeLatestVersion({ const latestVersion = await schemaVersions.getMaybeLatestSchemaVersionForTargetId(target.id);
targetId: target.id,
projectId: project.id,
organizationId: organization.id,
});
assertNonNull(latestVersion); assertNonNull(latestVersion);
expect(latestVersion.compositeSchemaSDL).toEqual(null); expect(latestVersion.compositeSchemaSDL).toEqual(null);

View file

@ -5,6 +5,7 @@ import { ProjectType } from 'testkit/gql/graphql';
import { execute } from 'testkit/graphql'; import { execute } from 'testkit/graphql';
import { assertNonNull, getServiceHost } from 'testkit/utils'; import { assertNonNull, getServiceHost } from 'testkit/utils';
import z from 'zod'; import z from 'zod';
import { SchemaVersionStore } from '@hive/api/modules/schema/providers/schema-version-store';
import { createPostgresDatabasePool, psql } from '@hive/postgres'; import { createPostgresDatabasePool, psql } from '@hive/postgres';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { createStorage } from '@hive/storage'; import { createStorage } from '@hive/storage';
@ -627,16 +628,12 @@ describe('schema publishing changes are persisted', () => {
return; return;
} }
const latestVersion = await storage.getMaybeLatestVersion({ const schemaVersions = new SchemaVersionStore(storage.pool);
targetId: target.id,
projectId: project.id, const latestVersion = await schemaVersions.getMaybeLatestSchemaVersionForTargetId(target.id);
organizationId: organization.id,
});
assertNonNull(latestVersion); assertNonNull(latestVersion);
const changes = await storage.getSchemaChangesForVersion({ const changes = await schemaVersions.getSchemaSchangesForSchemaVersion(latestVersion);
versionId: latestVersion.id,
});
if (!Array.isArray(changes)) { if (!Array.isArray(changes)) {
throw new Error('Expected changes to be an array'); throw new Error('Expected changes to be an array');
@ -3263,6 +3260,7 @@ const SchemaCompareToPreviousVersionQuery = graphql(`
test('Target.schemaVersion: result is read from the database', async () => { test('Target.schemaVersion: result is read from the database', async () => {
const storage = await createStorage(connectionString(), 1); const storage = await createStorage(connectionString(), 1);
const schemaVersions = new SchemaVersionStore(storage.pool);
try { try {
const serviceName = { const serviceName = {
@ -3305,11 +3303,7 @@ test('Target.schemaVersion: result is read from the database', async () => {
return; return;
} }
const latestVersion = await storage.getMaybeLatestVersion({ const latestVersion = await schemaVersions.getMaybeLatestSchemaVersionForTargetId(target.id);
targetId: target.id,
projectId: project.id,
organizationId: organization.id,
});
assertNonNull(latestVersion); assertNonNull(latestVersion);
const result = await execute({ const result = await execute({
@ -3346,6 +3340,7 @@ test('Target.schemaVersion: result is read from the database', async () => {
test('Composition Error (Federation 2) can be served from the database', async () => { test('Composition Error (Federation 2) can be served from the database', async () => {
const storage = await createStorage(connectionString(), 1); const storage = await createStorage(connectionString(), 1);
const schemaVersions = new SchemaVersionStore(storage.pool);
const serviceAddress = await getServiceHost('composition_federation_2', 3069, false); const serviceAddress = await getServiceHost('composition_federation_2', 3069, false);
try { try {
@ -3444,11 +3439,7 @@ test('Composition Error (Federation 2) can be served from the database', async (
return; return;
} }
const latestVersion = await storage.getMaybeLatestVersion({ const latestVersion = await schemaVersions.getMaybeLatestSchemaVersionForTargetId(target.id);
targetId: target.id,
projectId: project.id,
organizationId: organization.id,
});
assertNonNull(latestVersion); assertNonNull(latestVersion);
const result = await execute({ const result = await execute({
@ -3476,6 +3467,7 @@ test('Composition Error (Federation 2) can be served from the database', async (
test('Composition Network Failure (Federation 2)', async () => { test('Composition Network Failure (Federation 2)', async () => {
const storage = await createStorage(connectionString(), 1); const storage = await createStorage(connectionString(), 1);
const schemaVersions = new SchemaVersionStore(storage.pool);
const serviceAddress = await getServiceHost('composition_federation_2', 3069, false); const serviceAddress = await getServiceHost('composition_federation_2', 3069, false);
try { try {
@ -3610,11 +3602,7 @@ test('Composition Network Failure (Federation 2)', async () => {
return; return;
} }
const latestVersion = await storage.getMaybeLatestVersion({ const latestVersion = await schemaVersions.getMaybeLatestSchemaVersionForTargetId(target.id);
targetId: target.id,
projectId: project.id,
organizationId: organization.id,
});
assertNonNull(latestVersion); assertNonNull(latestVersion);
const result = await execute({ const result = await execute({
@ -4543,7 +4531,8 @@ test.concurrent(
const conn = connectionString(); const conn = connectionString();
const storage = await createStorage(conn, 2); const storage = await createStorage(conn, 2);
await storage.createVersion({ const schemaVersions = new SchemaVersionStore(storage.pool);
await schemaVersions.createSchemaVersion({
schema: brokenSdl, schema: brokenSdl,
author: 'Jochen', author: 'Jochen',
async actionFn() {}, async actionFn() {},
@ -4553,7 +4542,6 @@ test.concurrent(
compositeSchemaSDL: null, compositeSchemaSDL: null,
conditionalBreakingChangeMetadata: null, conditionalBreakingChangeMetadata: null,
contracts: null, contracts: null,
coordinatesDiff: null,
diffSchemaVersionId: null, diffSchemaVersionId: null,
github: null, github: null,
metadata: null, metadata: null,

View file

@ -12,10 +12,10 @@
}, },
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"packageManager": "pnpm@10.18.3+sha512.bbd16e6d7286fd7e01f6b3c0b3c932cda2965c06a908328f74663f10a9aea51f1129eea615134bf992831b009eabe167ecb7008b597f40ff9bc75946aadfb08d", "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
"engines": { "engines": {
"node": ">=24.14.1", "node": ">=24.14.1",
"pnpm": ">=10.16.0" "pnpm": ">=10.33.2"
}, },
"scripts": { "scripts": {
"build": "pnpm turbo build --filter='@hive/app' --concurrency=1 --color && pnpm turbo build --concurrency=4 --color --filter '!@hive/app'", "build": "pnpm turbo build --filter='@hive/app' --concurrency=1 --color && pnpm turbo build --concurrency=4 --color --filter '!@hive/app'",
@ -73,7 +73,7 @@
"@graphql-codegen/urql-introspection": "3.0.1", "@graphql-codegen/urql-introspection": "3.0.1",
"@graphql-eslint/eslint-plugin": "3.20.1", "@graphql-eslint/eslint-plugin": "3.20.1",
"@graphql-inspector/cli": "6.0.6", "@graphql-inspector/cli": "6.0.6",
"@graphql-inspector/core": "7.1.2", "@graphql-inspector/core": "7.1.3",
"@graphql-inspector/patch": "0.1.3", "@graphql-inspector/patch": "0.1.3",
"@graphql-tools/load": "8.1.2", "@graphql-tools/load": "8.1.2",
"@manypkg/get-packages": "2.2.2", "@manypkg/get-packages": "2.2.2",
@ -128,6 +128,7 @@
"overrides.ajv@8.x.x": "address https://github.com/graphql-hive/console/security/dependabot/507", "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.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.path-to-regexp@0.x.x": "address https://github.com/graphql-hive/console/security/dependabot/619",
"overrides.fast-uri@2.x.x": "address https://github.com/graphql-hive/console/security/dependabot/683",
"overrides": { "overrides": {
"esbuild": "0.25.9", "esbuild": "0.25.9",
"csstype": "3.1.2", "csstype": "3.1.2",
@ -163,7 +164,9 @@
"ajv@8.x.x": "^8.18.0", "ajv@8.x.x": "^8.18.0",
"yauzl@2.x.x": "^3.2.1", "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" "path-to-regexp@0.x.x": "^0.1.13",
"fast-uri@2.x.x": "3.x.x",
"protobufjs@8.x.x": "^8.0.2"
}, },
"patchedDependencies": { "patchedDependencies": {
"mjml-core@4.14.0": "patches/mjml-core@4.14.0.patch", "mjml-core@4.14.0": "patches/mjml-core@4.14.0.patch",

View file

@ -18,6 +18,20 @@ function psqlFn(template: TemplateStringsArray, ...values: ValueExpression[]) {
return tag.type(z.unknown())(template, ...values); return tag.type(z.unknown())(template, ...values);
} }
Object.assign(psqlFn, createSqlTag()); /**
* Small helper utility for jsonifying a nullable object.
*/
function jsonbOrNull<T>(obj: T | null | undefined) {
if (obj == null) return null;
return psqlFn`${JSON.stringify(obj)}::jsonb`;
}
export const psql = psqlFn as any as SqlTag<any> & CallableTag; Object.assign(psqlFn, tag, {
jsonbOrNull,
});
type UtilityExtensions = {
jsonbOrNull: typeof jsonbOrNull;
};
export const psql = psqlFn as any as SqlTag<any> & CallableTag & UtilityExtensions;

View file

@ -48,7 +48,7 @@
}, },
"dependencies": { "dependencies": {
"@graphql-hive/core": "workspace:*", "@graphql-hive/core": "workspace:*",
"@graphql-hive/logger": "^1.0.9" "@graphql-hive/logger": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@apollo/server": "5.5.0", "@apollo/server": "5.5.0",

View file

@ -1,5 +1,14 @@
# @graphql-hive/cli # @graphql-hive/cli
## 0.59.2
### Patch Changes
- [#8035](https://github.com/graphql-hive/console/pull/8035)
[`0cd6cc5`](https://github.com/graphql-hive/console/commit/0cd6cc5606e8cf3c952583feec956c8f024ee615)
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
[GHSA-rpmf-866q-6p89](https://github.com/advisories/GHSA-rpmf-866q-6p89)
## 0.59.1 ## 0.59.1
### Patch Changes ### Patch Changes

View file

@ -81,7 +81,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/app/create.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/app/create.ts)_ [src/commands/app/create.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/app/create.ts)_
## `hive app:publish` ## `hive app:publish`
@ -108,7 +108,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/app/publish.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/app/publish.ts)_ [src/commands/app/publish.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/app/publish.ts)_
## `hive app:retire` ## `hive app:retire`
@ -136,7 +136,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/app/retire.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/app/retire.ts)_ [src/commands/app/retire.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/app/retire.ts)_
## `hive artifact:fetch` ## `hive artifact:fetch`
@ -160,7 +160,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/artifact/fetch.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/artifact/fetch.ts)_ [src/commands/artifact/fetch.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/artifact/fetch.ts)_
## `hive dev` ## `hive dev`
@ -203,7 +203,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/dev.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/dev.ts)_ [src/commands/dev.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/dev.ts)_
## `hive help [COMMAND]` ## `hive help [COMMAND]`
@ -249,7 +249,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/introspect.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/introspect.ts)_ [src/commands/introspect.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/introspect.ts)_
## `hive operations:check FILE` ## `hive operations:check FILE`
@ -308,7 +308,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/operations/check.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/operations/check.ts)_ [src/commands/operations/check.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/operations/check.ts)_
## `hive schema:check FILE` ## `hive schema:check FILE`
@ -353,7 +353,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/schema/check.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/schema/check.ts)_ [src/commands/schema/check.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/schema/check.ts)_
## `hive schema:delete SERVICE` ## `hive schema:delete SERVICE`
@ -385,7 +385,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/schema/delete.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/schema/delete.ts)_ [src/commands/schema/delete.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/schema/delete.ts)_
## `hive schema:fetch [COMMIT]` ## `hive schema:fetch [COMMIT]`
@ -418,7 +418,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/schema/fetch.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/schema/fetch.ts)_ [src/commands/schema/fetch.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/schema/fetch.ts)_
## `hive schema:publish FILE` ## `hive schema:publish FILE`
@ -462,7 +462,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/schema/publish.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/schema/publish.ts)_ [src/commands/schema/publish.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/schema/publish.ts)_
## `hive update [CHANNEL]` ## `hive update [CHANNEL]`
@ -525,7 +525,7 @@ DESCRIPTION
``` ```
_See code: _See code:
[src/commands/whoami.ts](https://github.com/graphql-hive/platform/blob/v0.59.1/src/commands/whoami.ts)_ [src/commands/whoami.ts](https://github.com/graphql-hive/platform/blob/v0.59.2/src/commands/whoami.ts)_
<!-- commandsstop --> <!-- commandsstop -->

View file

@ -1,6 +1,6 @@
{ {
"name": "@graphql-hive/cli", "name": "@graphql-hive/cli",
"version": "0.59.1", "version": "0.59.2",
"description": "A CLI util to manage and control your GraphQL Hive", "description": "A CLI util to manage and control your GraphQL Hive",
"repository": { "repository": {
"type": "git", "type": "git",
@ -49,7 +49,7 @@
}, },
"dependencies": { "dependencies": {
"@graphql-hive/core": "workspace:*", "@graphql-hive/core": "workspace:*",
"@graphql-inspector/core": "7.1.2", "@graphql-inspector/core": "7.1.3",
"@graphql-tools/code-file-loader": "~8.1.0", "@graphql-tools/code-file-loader": "~8.1.0",
"@graphql-tools/graphql-file-loader": "~8.1.0", "@graphql-tools/graphql-file-loader": "~8.1.0",
"@graphql-tools/json-file-loader": "~8.0.0", "@graphql-tools/json-file-loader": "~8.0.0",

View file

@ -46,7 +46,7 @@
"graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" "graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
}, },
"dependencies": { "dependencies": {
"@graphql-hive/logger": "^1.0.9", "@graphql-hive/logger": "^1.1.0",
"@graphql-hive/signal": "^2.0.0", "@graphql-hive/signal": "^2.0.0",
"@graphql-tools/utils": "^10.0.0", "@graphql-tools/utils": "^10.0.0",
"@whatwg-node/fetch": "^0.10.13", "@whatwg-node/fetch": "^0.10.13",

View file

@ -1,5 +1,14 @@
# @graphql-hive/laboratory # @graphql-hive/laboratory
## 0.1.8
### Patch Changes
- [#8024](https://github.com/graphql-hive/console/pull/8024)
[`0e3ce40`](https://github.com/graphql-hive/console/commit/0e3ce400706c625925161f8d59cc5691380cef07)
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Hive laboratory introspection query to
include active tab headers
## 0.1.7 ## 0.1.7
### Patch Changes ### Patch Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@graphql-hive/laboratory", "name": "@graphql-hive/laboratory",
"version": "0.1.7", "version": "0.1.8",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"main": "./dist/hive-laboratory.cjs.js", "main": "./dist/hive-laboratory.cjs.js",

View file

@ -19,6 +19,7 @@ import {
ListTreeIcon, ListTreeIcon,
RotateCcwIcon, RotateCcwIcon,
SearchIcon, SearchIcon,
SettingsIcon,
TextAlignStartIcon, TextAlignStartIcon,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -759,7 +760,17 @@ export const Builder = (props: {
operationName?: string | null; operationName?: string | null;
isReadOnly?: boolean; isReadOnly?: boolean;
}) => { }) => {
const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = useLaboratory(); const {
schema,
activeOperation,
endpoint,
setEndpoint,
defaultEndpoint,
tabs,
addTab,
setActiveTab,
shouldPollSchema,
} = useLaboratory();
const [endpointValue, setEndpointValue] = useState<string>(endpoint ?? ''); const [endpointValue, setEndpointValue] = useState<string>(endpoint ?? '');
const [searchValue, setSearchValue] = useState<string>(''); const [searchValue, setSearchValue] = useState<string>('');
@ -845,9 +856,30 @@ export const Builder = (props: {
return ( return (
<div className="bg-card flex size-full flex-col overflow-hidden"> <div className="bg-card flex size-full flex-col overflow-hidden">
<div className="flex items-center px-3 pt-3"> <div className="flex items-center gap-3 px-3 pt-3">
<span className="text-base font-medium">Builder</span> <span className="text-base font-medium">Builder</span>
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center gap-3">
{shouldPollSchema && (
<Button
onClick={() => {
const tab =
tabs.find(t => t.type === 'settings') ??
addTab({
type: 'settings',
data: {},
});
setActiveTab(tab);
}}
variant="ghost"
size="sm"
className="p-1! h-6 rounded-sm !px-1.5"
>
<SettingsIcon className="size-4" />
Introspection settings
</Button>
)}
<div className="flex items-center">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -864,6 +896,7 @@ export const Builder = (props: {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
</div>
<div className="px-3 pt-3"> <div className="px-3 pt-3">
<InputGroup> <InputGroup>
<InputGroupInput <InputGroupInput

View file

@ -12,6 +12,7 @@ import { OperationDefinitionNode, parse } from 'graphql';
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
import { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js'; import { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js';
import { initializeMode } from 'monaco-graphql/initializeMode'; import { initializeMode } from 'monaco-graphql/initializeMode';
import { cn } from '@/lib/utils';
import MonacoEditor, { loader } from '@monaco-editor/react'; import MonacoEditor, { loader } from '@monaco-editor/react';
import { useLaboratory } from './context'; import { useLaboratory } from './context';
@ -84,7 +85,7 @@ const darkTheme: monaco.editor.IStandaloneThemeData = {
], ],
colors: { colors: {
'editor.foreground': '#f6f8fa', 'editor.foreground': '#f6f8fa',
'editor.background': '#0f1214', 'editor.background': '#0f121400',
'editor.selectionBackground': '#2A2F34', 'editor.selectionBackground': '#2A2F34',
'editor.inactiveSelectionBackground': '#2A2F34', 'editor.inactiveSelectionBackground': '#2A2F34',
'editor.lineHighlightBackground': '#2A2F34', 'editor.lineHighlightBackground': '#2A2F34',
@ -354,10 +355,10 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
} }
return ( return (
<div className="size-full overflow-hidden"> <div className={cn('size-full overflow-hidden', props.className)}>
<MonacoEditor <MonacoEditor
className="size-full"
{...props} {...props}
className="size-full"
theme={theme === 'dark' ? 'hive-laboratory-dark' : 'hive-laboratory-light'} theme={theme === 'dark' ? 'hive-laboratory-dark' : 'hive-laboratory-light'}
onMount={handleMount} onMount={handleMount}
loading={null} loading={null}

View file

@ -514,15 +514,10 @@ export const Laboratory = (
const pluginsApi = usePlugins(props); const pluginsApi = usePlugins(props);
const testsApi = useTests(props); const testsApi = useTests(props);
const tabsApi = useTabs(props); const tabsApi = useTabs(props);
const endpointApi = useEndpoint({
...props,
settingsApi,
});
const collectionsApi = useCollections({ const collectionsApi = useCollections({
...props, ...props,
tabsApi, tabsApi,
}); });
const operationsApi = useOperations({ const operationsApi = useOperations({
...props, ...props,
collectionsApi, collectionsApi,
@ -533,6 +528,14 @@ export const Laboratory = (
pluginsApi, pluginsApi,
checkPermissions, checkPermissions,
}); });
const endpointApi = useEndpoint({
...props,
settingsApi,
operationsApi,
envApi,
pluginsApi,
preflightApi,
});
const historyApi = useHistory(props); const historyApi = useHistory(props);

View file

@ -1,9 +1,11 @@
import { useEffect } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { Editor } from '@/components/laboratory/editor';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldDescription, FieldGroup, FieldLabel } from '../ui/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { useLaboratory } from './context'; import { useLaboratory } from './context';
@ -20,6 +22,8 @@ const settingsFormSchema = z.object({
introspection: z.object({ introspection: z.object({
method: z.enum(['GET', 'POST']).optional(), method: z.enum(['GET', 'POST']).optional(),
schemaDescription: z.boolean().optional(), schemaDescription: z.boolean().optional(),
headers: z.string().optional(),
includeActiveOperationHeaders: z.boolean().optional(),
}), }),
}); });
@ -31,19 +35,17 @@ export const Settings = () => {
validators: { validators: {
onSubmit: settingsFormSchema, onSubmit: settingsFormSchema,
}, },
onSubmit: ({ value }) => {
setSettings(value as typeof settings);
},
}); });
useEffect(() => {
form.store.subscribe(state => {
setSettings(state.currentVal.values);
});
}, [setSettings]);
return ( return (
<div className="bg-card size-full overflow-y-auto p-3"> <div className="bg-card size-full overflow-y-auto p-3">
<form <form id="settings-form" className="mx-auto flex max-w-2xl flex-col gap-4">
id="settings-form"
onSubmit={form.handleSubmit}
onChange={form.handleSubmit}
className="mx-auto flex max-w-2xl flex-col gap-4"
>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Fetch</CardTitle> <CardTitle>Fetch</CardTitle>
@ -220,6 +222,43 @@ export const Settings = () => {
); );
}} }}
</form.Field> </form.Field>
<form.Field name="introspection.headers">
{field => {
return (
<Field>
<FieldLabel htmlFor={field.name}>Headers</FieldLabel>
<Editor
value={field.state.value ?? '{}'}
onChange={field.handleChange}
defaultLanguage="json"
theme="hive-laboratory"
className="bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 h-64 rounded rounded-md border focus-within:ring-[3px]"
/>
</Field>
);
}}
</form.Field>
<form.Field name="introspection.includeActiveOperationHeaders">
{field => {
return (
<Field className="flex-row items-start">
<Switch
className="mt-0.5 !w-8"
checked={field.state.value ?? false}
onCheckedChange={field.handleChange}
/>
<div>
<FieldLabel htmlFor={field.name}>
Include active operation headers
</FieldLabel>
<FieldDescription>
Active operation (tab) headers will be included in the introspection query
</FieldDescription>
</div>
</Field>
);
}}
</form.Field>
</FieldGroup> </FieldGroup>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -93,6 +93,10 @@
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1)); --destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
--ring: var(--hive-laboratory-ring, var(--color-ring)); --ring: var(--hive-laboratory-ring, var(--color-ring));
& .monaco-editor {
--vscode-focusBorder: transparent !important;
}
} }
.hive-laboratory.dark { .hive-laboratory.dark {

View file

@ -1,12 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
buildClientSchema, buildClientSchema,
GraphQLSchema, GraphQLSchema,
introspectionFromSchema, introspectionFromSchema,
type IntrospectionQuery, type IntrospectionQuery,
} from 'graphql'; } from 'graphql';
import { debounce } from 'lodash';
import { toast } from 'sonner'; import { toast } from 'sonner';
// import z from 'zod'; import { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/lib/env';
import { LaboratoryOperationsActions, LaboratoryOperationsState } from '@/lib/operations';
import { handleTemplate } from '@/lib/operations.utils';
import { LaboratoryPluginsActions, LaboratoryPluginsState } from '@/lib/plugins';
import { LaboratoryPreflightActions, LaboratoryPreflightState } from '@/lib/preflight';
import { asyncInterval } from '@/lib/utils'; import { asyncInterval } from '@/lib/utils';
import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader'; import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings'; import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
@ -16,6 +21,7 @@ export interface LaboratoryEndpointState {
schema: GraphQLSchema | null; schema: GraphQLSchema | null;
introspection: IntrospectionQuery | null; introspection: IntrospectionQuery | null;
defaultEndpoint: string | null; defaultEndpoint: string | null;
shouldPollSchema: boolean;
} }
export interface LaboratoryEndpointActions { export interface LaboratoryEndpointActions {
@ -24,11 +30,17 @@ export interface LaboratoryEndpointActions {
restoreDefaultEndpoint: () => void; restoreDefaultEndpoint: () => void;
} }
export const EXPECTED_ERROR_REASON = 'Expected error reason';
export const useEndpoint = (props: { export const useEndpoint = (props: {
defaultEndpoint?: string | null; defaultEndpoint?: string | null;
onEndpointChange?: (endpoint: string | null) => void; onEndpointChange?: (endpoint: string | null) => void;
defaultSchemaIntrospection?: IntrospectionQuery | null; defaultSchemaIntrospection?: IntrospectionQuery | null;
settingsApi?: LaboratorySettingsState & LaboratorySettingsActions; settingsApi?: LaboratorySettingsState & LaboratorySettingsActions;
operationsApi?: LaboratoryOperationsState & LaboratoryOperationsActions;
envApi?: LaboratoryEnvState & LaboratoryEnvActions;
pluginsApi?: LaboratoryPluginsState & LaboratoryPluginsActions;
preflightApi?: LaboratoryPreflightState & LaboratoryPreflightActions;
}): LaboratoryEndpointState & LaboratoryEndpointActions => { }): LaboratoryEndpointState & LaboratoryEndpointActions => {
const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null); const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null);
const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null); const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null);
@ -47,8 +59,28 @@ export const useEndpoint = (props: {
const loader = useMemo(() => new UrlLoader(), []); const loader = useMemo(() => new UrlLoader(), []);
const fetchSchema = useCallback( const activeOperationHeadersRef = useRef<string | null | undefined>(
async (signal?: AbortSignal) => { props.operationsApi?.activeOperation?.headers,
);
const envVariablesRef = useRef<LaboratoryEnv['variables'] | undefined>(
props.envApi?.env?.variables,
);
const pluginsStateRef = useRef<Record<string, any> | undefined>(props.pluginsApi?.pluginsState);
activeOperationHeadersRef.current = props.operationsApi?.activeOperation?.headers;
envVariablesRef.current = props.envApi?.env?.variables;
pluginsStateRef.current = props.pluginsApi?.pluginsState;
const fetchSchema = useMemo(
() =>
debounce(
async (
signal?: AbortSignal,
options?: {
env?: LaboratoryEnv;
pluginsState?: Record<string, any>;
},
) => {
if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) { if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) {
setIntrospection(props.defaultSchemaIntrospection); setIntrospection(props.defaultSchemaIntrospection);
return; return;
@ -60,11 +92,75 @@ export const useEndpoint = (props: {
} }
try { try {
let env = options?.env?.variables ?? envVariablesRef.current ?? {};
let plugins = options?.pluginsState ?? pluginsStateRef.current ?? {};
let sourceHeaders: Record<string, string> = {};
if (props.settingsApi?.settings.introspection.headers) {
try {
sourceHeaders = JSON.parse(props.settingsApi?.settings.introspection.headers);
} catch {}
}
if (
props.settingsApi?.settings.introspection.includeActiveOperationHeaders &&
activeOperationHeadersRef.current
) {
try {
sourceHeaders = {
...sourceHeaders,
...JSON.parse(activeOperationHeadersRef.current),
};
} catch {}
}
let stringifiedHeaders = JSON.stringify(sourceHeaders);
if (stringifiedHeaders.includes('{{')) {
try {
const preflightResult = await props.preflightApi?.runPreflight?.(
props.pluginsApi?.plugins ?? [],
props.pluginsApi?.pluginsState ?? {},
);
props?.envApi?.setEnv(preflightResult?.env ?? { variables: {} });
props?.pluginsApi?.setPluginsState(preflightResult?.pluginsState ?? {});
env = preflightResult?.env?.variables ?? {};
plugins = preflightResult?.pluginsState ?? {};
if (preflightResult?.headers) {
stringifiedHeaders = JSON.stringify({
...sourceHeaders,
...preflightResult?.headers,
});
}
} catch (error: unknown) {
toast.error('Failed to run preflight');
}
}
let parsedHeaders: Record<string, string> = {};
try {
parsedHeaders = JSON.parse(
handleTemplate(stringifiedHeaders, {
...env,
plugins,
}),
);
} catch (error: unknown) {
toast.error('Failed to parse headers');
parsedHeaders = {};
}
const result = await loader.load(endpoint, { const result = await loader.load(endpoint, {
subscriptionsEndpoint: endpoint, subscriptionsEndpoint: endpoint,
subscriptionsProtocol: subscriptionsProtocol:
(props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ?? (props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
SubscriptionProtocol.GRAPHQL_SSE, SubscriptionProtocol.GRAPHQL_SSE,
headers: parsedHeaders,
credentials: props.settingsApi?.settings.fetch.credentials, credentials: props.settingsApi?.settings.fetch.credentials,
specifiedByUrl: true, specifiedByUrl: true,
directiveIsRepeatable: true, directiveIsRepeatable: true,
@ -98,6 +194,10 @@ export const useEndpoint = (props: {
'message' in error && 'message' in error &&
typeof error.message === 'string' typeof error.message === 'string'
) { ) {
if (error.message === EXPECTED_ERROR_REASON) {
return;
}
toast.error(error.message); toast.error(error.message);
} else { } else {
toast.error('Failed to fetch schema'); toast.error('Failed to fetch schema');
@ -108,14 +208,24 @@ export const useEndpoint = (props: {
throw error; throw error;
} }
}, },
500,
),
[ [
endpoint, endpoint,
props.settingsApi?.settings.fetch.timeout, props.settingsApi?.settings.fetch.timeout,
props.settingsApi?.settings.introspection.method, props.settingsApi?.settings.introspection.method,
props.settingsApi?.settings.introspection.schemaDescription, props.settingsApi?.settings.introspection.schemaDescription,
props.settingsApi?.settings.introspection.headers,
props.settingsApi?.settings.introspection.includeActiveOperationHeaders,
], ],
); );
useEffect(() => {
return () => {
fetchSchema.cancel();
};
}, [fetchSchema]);
const shouldPollSchema = useMemo(() => { const shouldPollSchema = useMemo(() => {
return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection; return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection;
}, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]); }, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]);
@ -132,7 +242,7 @@ export const useEndpoint = (props: {
try { try {
await fetchSchema(intervalController.signal); await fetchSchema(intervalController.signal);
} catch { } catch {
intervalController.abort('Polling schema failed'); intervalController.abort(new Error('Aborted because of schema polling error'));
} }
}, },
5000, 5000,
@ -140,7 +250,7 @@ export const useEndpoint = (props: {
); );
return () => { return () => {
intervalController.abort('Polling schema aborted'); intervalController.abort(new Error(EXPECTED_ERROR_REASON));
}; };
}, [shouldPollSchema, fetchSchema]); }, [shouldPollSchema, fetchSchema]);
@ -152,10 +262,38 @@ export const useEndpoint = (props: {
useEffect(() => { useEffect(() => {
if (endpoint && !shouldPollSchema) { if (endpoint && !shouldPollSchema) {
void fetchSchema(); const abortController = new AbortController();
void fetchSchema(abortController.signal);
return () => {
abortController.abort(new Error(EXPECTED_ERROR_REASON));
};
} }
}, [endpoint, fetchSchema, shouldPollSchema]); }, [endpoint, fetchSchema, shouldPollSchema]);
useEffect(() => {
if (!endpoint || !shouldPollSchema) {
return;
}
const abortController = new AbortController();
void fetchSchema(abortController.signal, {
env: props.envApi?.env ?? undefined,
pluginsState: props.pluginsApi?.pluginsState,
});
return () => {
abortController.abort(new Error(EXPECTED_ERROR_REASON));
};
}, [
endpoint,
shouldPollSchema,
fetchSchema,
props.settingsApi?.settings.introspection.headers,
props.settingsApi?.settings.introspection.includeActiveOperationHeaders,
]);
return { return {
endpoint, endpoint,
setEndpoint, setEndpoint,
@ -164,5 +302,6 @@ export const useEndpoint = (props: {
fetchSchema, fetchSchema,
restoreDefaultEndpoint, restoreDefaultEndpoint,
defaultEndpoint: props.defaultEndpoint ?? null, defaultEndpoint: props.defaultEndpoint ?? null,
shouldPollSchema,
}; };
}; };

View file

@ -13,6 +13,8 @@ export type LaboratorySettings = {
introspection: { introspection: {
method?: 'GET' | 'POST'; method?: 'GET' | 'POST';
schemaDescription?: boolean; schemaDescription?: boolean;
headers?: string;
includeActiveOperationHeaders?: boolean;
}; };
}; };
@ -29,6 +31,8 @@ export const defaultLaboratorySettings: LaboratorySettings = {
introspection: { introspection: {
method: 'POST', method: 'POST',
schemaDescription: false, schemaDescription: false,
headers: '',
includeActiveOperationHeaders: false,
}, },
}; };
@ -50,6 +54,10 @@ export const normalizeLaboratorySettings = (
schemaDescription: schemaDescription:
settings?.introspection?.schemaDescription ?? settings?.introspection?.schemaDescription ??
defaultLaboratorySettings.introspection.schemaDescription, defaultLaboratorySettings.introspection.schemaDescription,
headers: settings?.introspection?.headers ?? defaultLaboratorySettings.introspection.headers,
includeActiveOperationHeaders:
settings?.introspection?.includeActiveOperationHeaders ??
defaultLaboratorySettings.introspection.includeActiveOperationHeaders,
}, },
}); });

View file

@ -1,5 +1,18 @@
# @graphql-yoga/render-graphiql # @graphql-yoga/render-graphiql
## 0.1.8
### Patch Changes
- [#8024](https://github.com/graphql-hive/console/pull/8024)
[`0e3ce40`](https://github.com/graphql-hive/console/commit/0e3ce400706c625925161f8d59cc5691380cef07)
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Hive laboratory introspection query to
include active tab headers
- Updated dependencies
[[`0e3ce40`](https://github.com/graphql-hive/console/commit/0e3ce400706c625925161f8d59cc5691380cef07)]:
- @graphql-hive/laboratory@0.1.8
## 0.1.7 ## 0.1.7
### Patch Changes ### Patch Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@graphql-hive/render-laboratory", "name": "@graphql-hive/render-laboratory",
"version": "0.1.7", "version": "0.1.8",
"type": "module", "type": "module",
"description": "", "description": "",
"repository": { "repository": {

View file

@ -48,7 +48,7 @@
}, },
"dependencies": { "dependencies": {
"@graphql-hive/core": "workspace:*", "@graphql-hive/core": "workspace:*",
"@graphql-hive/logger": "^1.0.9", "@graphql-hive/logger": "^1.1.0",
"@graphql-yoga/plugin-persisted-operations": "^3.9.0" "@graphql-yoga/plugin-persisted-operations": "^3.9.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -10,7 +10,8 @@
"./modules/auth/lib/supertokens-at-home/crypto": "./src/modules/auth/lib/supertokens-at-home/crypto.ts", "./modules/auth/lib/supertokens-at-home/crypto": "./src/modules/auth/lib/supertokens-at-home/crypto.ts",
"./modules/auth/providers/supertokens-store": "./src/modules/auth/providers/supertokens-store.ts", "./modules/auth/providers/supertokens-store": "./src/modules/auth/providers/supertokens-store.ts",
"./modules/shared/providers/logger": "./src/modules/shared/providers/logger.ts", "./modules/shared/providers/logger": "./src/modules/shared/providers/logger.ts",
"./modules/shared/providers/redis": "./src/modules/shared/providers/redis.ts" "./modules/shared/providers/redis": "./src/modules/shared/providers/redis.ts",
"./modules/schema/providers/schema-version-store": "./src/modules/schema/providers/schema-version-store.ts"
}, },
"peerDependencies": { "peerDependencies": {
"graphql": "^16.0.0", "graphql": "^16.0.0",
@ -23,7 +24,7 @@
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@graphql-hive/core": "workspace:*", "@graphql-hive/core": "workspace:*",
"@graphql-hive/signal": "1.0.0", "@graphql-hive/signal": "1.0.0",
"@graphql-inspector/core": "7.1.2", "@graphql-inspector/core": "7.1.3",
"@graphql-tools/merge": "9.1.1", "@graphql-tools/merge": "9.1.1",
"@hive/cdn-script": "workspace:*", "@hive/cdn-script": "workspace:*",
"@hive/postgres": "workspace:*", "@hive/postgres": "workspace:*",

View file

@ -124,10 +124,10 @@ export class AppDeploymentsManager {
}, },
}); });
const target = await this.targetManager.getTargetById(selector);
return await this.appDeployments.addDocumentsToAppDeployment({ return await this.appDeployments.addDocumentsToAppDeployment({
organizationId: selector.organizationId, target,
projectId: selector.projectId,
targetId: selector.targetId,
appDeployment: args.appDeployment, appDeployment: args.appDeployment,
operations: args.documents, operations: args.documents,
}); });

View file

@ -1,7 +1,11 @@
import { differenceInCalendarDays, startOfDay, subDays } from 'date-fns'; import { differenceInCalendarDays, startOfDay, subDays } from 'date-fns';
import { Inject, Injectable, Scope } from 'graphql-modules'; import { Inject, Injectable, Scope } from 'graphql-modules';
import { z } from 'zod'; import { z } from 'zod';
import { buildAppDeploymentIsEnabledKey } from '@hive/cdn-script/artifact-storage-reader'; import {
AppDeploymentManifestModel,
buildAppDeploymentIsEnabledKey,
buildAppDeploymentManifestKey,
} from '@hive/cdn-script/artifact-storage-reader';
import { import {
PostgresDatabasePool, PostgresDatabasePool,
psql, psql,
@ -16,8 +20,10 @@ import {
encodeCreatedAtAndUUIDIdBasedCursor, encodeCreatedAtAndUUIDIdBasedCursor,
encodeHashBasedCursor, encodeHashBasedCursor,
} from '@hive/storage'; } from '@hive/storage';
import type { Target } from '../../../shared/entities';
import { ClickHouse, sql as cSql } from '../../operations/providers/clickhouse-client'; import { ClickHouse, sql as cSql } from '../../operations/providers/clickhouse-client';
import { SchemaVersionHelper } from '../../schema/providers/schema-version-helper'; import { SchemaVersionHelper } from '../../schema/providers/schema-version-helper';
import { SchemaVersionStore } from '../../schema/providers/schema-version-store';
import { Logger } from '../../shared/providers/logger'; import { Logger } from '../../shared/providers/logger';
import { S3_CONFIG, type S3Config } from '../../shared/providers/s3-config'; import { S3_CONFIG, type S3Config } from '../../shared/providers/s3-config';
import { Storage } from '../../shared/providers/storage'; import { Storage } from '../../shared/providers/storage';
@ -55,6 +61,7 @@ export class AppDeployments {
private storage: Storage, private storage: Storage,
private schemaVersionHelper: SchemaVersionHelper, private schemaVersionHelper: SchemaVersionHelper,
private persistedDocumentScheduler: PersistedDocumentScheduler, private persistedDocumentScheduler: PersistedDocumentScheduler,
private schemaVersions: SchemaVersionStore,
@Inject(APP_DEPLOYMENTS_ENABLED) private appDeploymentsEnabled: boolean, @Inject(APP_DEPLOYMENTS_ENABLED) private appDeploymentsEnabled: boolean,
) { ) {
this.logger = logger.child({ source: 'AppDeployments' }); this.logger = logger.child({ source: 'AppDeployments' });
@ -249,9 +256,7 @@ export class AppDeployments {
} }
async addDocumentsToAppDeployment(args: { async addDocumentsToAppDeployment(args: {
organizationId: string; target: Target;
projectId: string;
targetId: string;
appDeployment: { appDeployment: {
name: string; name: string;
version: string; version: string;
@ -263,7 +268,7 @@ export class AppDeployments {
}) { }) {
if (this.appDeploymentsEnabled === false) { if (this.appDeploymentsEnabled === false) {
const organization = await this.storage.getOrganization({ const organization = await this.storage.getOrganization({
organizationId: args.organizationId, organizationId: args.target.orgId,
}); });
if (organization.featureFlags.appDeployments === false) { if (organization.featureFlags.appDeployments === false) {
this.logger.debug( this.logger.debug(
@ -283,7 +288,7 @@ export class AppDeployments {
// todo: validate input // todo: validate input
const appDeployment = await this.findAppDeployment({ const appDeployment = await this.findAppDeployment({
targetId: args.targetId, targetId: args.target.id,
name: args.appDeployment.name, name: args.appDeployment.name,
version: args.appDeployment.version, version: args.appDeployment.version,
}); });
@ -309,9 +314,9 @@ export class AppDeployments {
} }
if (args.operations.length !== 0) { if (args.operations.length !== 0) {
const latestSchemaVersion = await this.storage.getMaybeLatestValidVersion({ const latestSchemaVersion = await this.schemaVersions.getMaybeLatestValidSchemaVersion(
targetId: args.targetId, args.target,
}); );
if (latestSchemaVersion === null) { if (latestSchemaVersion === null) {
return { return {
@ -326,9 +331,9 @@ export class AppDeployments {
const compositeSchemaSdl = await this.schemaVersionHelper.getCompositeSchemaSdl({ const compositeSchemaSdl = await this.schemaVersionHelper.getCompositeSchemaSdl({
...latestSchemaVersion, ...latestSchemaVersion,
organizationId: args.organizationId, organizationId: args.target.orgId,
projectId: args.projectId, projectId: args.target.projectId,
targetId: args.targetId, targetId: args.target.id,
}); });
if (compositeSchemaSdl === null) { if (compositeSchemaSdl === null) {
// No valid schema found. // No valid schema found.
@ -343,7 +348,7 @@ export class AppDeployments {
const result = await this.persistedDocumentScheduler.processBatch({ const result = await this.persistedDocumentScheduler.processBatch({
schemaSdl: compositeSchemaSdl, schemaSdl: compositeSchemaSdl,
targetId: args.targetId, targetId: args.target.id,
appDeployment: { appDeployment: {
id: appDeployment.id, id: appDeployment.id,
name: args.appDeployment.name, name: args.appDeployment.name,
@ -366,6 +371,27 @@ export class AppDeployments {
}; };
} }
private async _getAllDocumentHashesForAppDeployment(
appDeployment: AppDeploymentRecord,
): Promise<Array<string>> {
return await this.clickhouse
.query({
query: cSql`
SELECT
DISTINCT "document_hash" AS "hash"
FROM
"app_deployment_documents"
WHERE
"app_deployment_id" = ${appDeployment.id}
`,
queryId: 'app-deployment-document-ids',
timeout: 10_000,
})
.then(res =>
z.array(z.object({ hash: z.string() }).transform(row => row.hash)).parse(res.data),
);
}
async activateAppDeployment(args: { async activateAppDeployment(args: {
organizationId: string; organizationId: string;
targetId: string; targetId: string;
@ -431,8 +457,11 @@ export class AppDeployments {
}; };
} }
const appDeploymentDocumentHashes =
await this._getAllDocumentHashesForAppDeployment(appDeployment);
for (const s3 of this.s3) { for (const s3 of this.s3) {
const result = await s3.client.fetch( let result = await s3.client.fetch(
[ [
s3.endpoint, s3.endpoint,
s3.bucket, s3.bucket,
@ -457,6 +486,38 @@ export class AppDeployments {
if (result.statusCode !== 200) { if (result.statusCode !== 200) {
throw new Error(`Failed to enable app deployment: ${result.statusMessage}`); throw new Error(`Failed to enable app deployment: ${result.statusMessage}`);
} }
result = await s3.client.fetch(
[
s3.endpoint,
s3.bucket,
buildAppDeploymentManifestKey(
appDeployment.targetId,
appDeployment.name,
appDeployment.version,
),
].join('/'),
{
method: 'PUT',
body: JSON.stringify({
id: appDeployment.id,
appName: appDeployment.name,
appVersion: appDeployment.version,
documentHashes: appDeploymentDocumentHashes.sort(),
isActive: true,
} satisfies z.TypeOf<typeof AppDeploymentManifestModel>),
headers: {
'content-type': 'application/json',
},
aws: {
signQuery: true,
},
},
);
if (result.statusCode !== 200) {
throw new Error(`Failed to write app manifest: ${result.statusMessage}`);
}
} }
const updatedAppDeployment = await this.pool const updatedAppDeployment = await this.pool
@ -690,8 +751,11 @@ export class AppDeployments {
} }
} }
const appDeploymentDocumentHashes =
await this._getAllDocumentHashesForAppDeployment(appDeployment);
for (const s3 of this.s3) { for (const s3 of this.s3) {
const result = await s3.client.fetch( let result = await s3.client.fetch(
[ [
s3.endpoint, s3.endpoint,
s3.bucket, s3.bucket,
@ -722,6 +786,38 @@ export class AppDeployments {
`Failed to disable app deployment. Request failed with status code "${result.statusMessage}".`, `Failed to disable app deployment. Request failed with status code "${result.statusMessage}".`,
); );
} }
result = await s3.client.fetch(
[
s3.endpoint,
s3.bucket,
buildAppDeploymentManifestKey(
appDeployment.targetId,
appDeployment.name,
appDeployment.version,
),
].join('/'),
{
method: 'PUT',
body: JSON.stringify({
id: appDeployment.id,
appName: appDeployment.name,
appVersion: appDeployment.version,
documentHashes: appDeploymentDocumentHashes.sort(),
isActive: false,
} satisfies z.TypeOf<typeof AppDeploymentManifestModel>),
headers: {
'content-type': 'application/json',
},
aws: {
signQuery: true,
},
},
);
if (result.statusCode !== 200) {
throw new Error(`Failed to write app manifest: ${result.statusMessage}`);
}
} }
await this.clickhouse.query({ await this.clickhouse.query({

View file

@ -9,7 +9,7 @@ const SharedOIDCIntegrationDomainFieldsModel = z.object({
id: z.string().uuid(), id: z.string().uuid(),
organizationId: z.string().uuid(), organizationId: z.string().uuid(),
oidcIntegrationId: z.string().uuid(), oidcIntegrationId: z.string().uuid(),
domainName: z.string(), domainName: z.string().transform(name => name.toLowerCase()),
createdAt: z.string(), createdAt: z.string(),
}); });
@ -86,7 +86,7 @@ export class OIDCIntegrationStore {
) VALUES ( ) VALUES (
${organizationId} ${organizationId}
, ${oidcIntegrationId} , ${oidcIntegrationId}
, ${domainName} , ${domainName.toLowerCase()}
) )
ON CONFLICT ("oidc_integration_id", "domain_name") ON CONFLICT ("oidc_integration_id", "domain_name")
DO NOTHING DO NOTHING
@ -131,7 +131,7 @@ export class OIDCIntegrationStore {
FROM FROM
"oidc_integration_domains" "oidc_integration_domains"
WHERE WHERE
"domain_name" = ${domainName} "domain_name" = ${domainName.toLowerCase()}
AND "verified_at" IS NOT NULL AND "verified_at" IS NOT NULL
`; `;
@ -149,7 +149,7 @@ export class OIDCIntegrationStore {
"oidc_integration_domains" "oidc_integration_domains"
WHERE WHERE
"oidc_integration_id" = ${oidcIntegrationId} "oidc_integration_id" = ${oidcIntegrationId}
AND "domain_name" = ${domainName} AND "domain_name" = ${domainName.toLowerCase()}
AND "verified_at" IS NOT NULL AND "verified_at" IS NOT NULL
`; `;

View file

@ -5,6 +5,7 @@ import * as GraphQLSchema from '../../../__generated__/types';
import { Organization, ProjectType } from '../../../shared/entities'; import { Organization, ProjectType } from '../../../shared/entities';
import { AccessError } from '../../../shared/errors'; import { AccessError } from '../../../shared/errors';
import { Session } from '../../auth/lib/authz'; import { Session } from '../../auth/lib/authz';
import { SchemaVersionStore } from '../../schema/providers/schema-version-store';
import { Storage } from '../../shared/providers/storage'; import { Storage } from '../../shared/providers/storage';
/** /**
@ -19,6 +20,7 @@ export class ResourceSelector {
constructor( constructor(
private storage: Storage, private storage: Storage,
private session: Session, private session: Session,
private schemaVersions: SchemaVersionStore,
) {} ) {}
private async _assertResourceSelectorAdminPermissions(organizationId: string) { private async _assertResourceSelectorAdminPermissions(organizationId: string) {
@ -163,7 +165,9 @@ export class ResourceSelector {
if (target.type === GraphQLSchema.ProjectType.SINGLE) { if (target.type === GraphQLSchema.ProjectType.SINGLE) {
return null; return null;
} }
const latest = await this.storage.getMaybeLatestValidVersion({ targetId: target.targetId }); const latest = await this.schemaVersions.getMaybeLatestSchemaVersionForTargetId(
target.targetId,
);
if (latest) { if (latest) {
return await this.storage.pool return await this.storage.pool
.anyFirst( .anyFirst(

View file

@ -12,6 +12,7 @@ import { SchemaHelper } from './providers/schema-helper';
import { SchemaManager } from './providers/schema-manager'; import { SchemaManager } from './providers/schema-manager';
import { SchemaPublisher } from './providers/schema-publisher'; import { SchemaPublisher } from './providers/schema-publisher';
import { SchemaVersionHelper } from './providers/schema-version-helper'; import { SchemaVersionHelper } from './providers/schema-version-helper';
import { SchemaVersionStore } from './providers/schema-version-store';
import { resolvers } from './resolvers.generated'; import { resolvers } from './resolvers.generated';
import typeDefs from './module.graphql'; import typeDefs from './module.graphql';
@ -21,6 +22,7 @@ export const schemaModule = createModule({
typeDefs, typeDefs,
resolvers, resolvers,
providers: [ providers: [
SchemaVersionStore,
SchemaManager, SchemaManager,
SchemaPublisher, SchemaPublisher,
Inspector, Inspector,

View file

@ -1,10 +1,5 @@
import type { DocumentNode, GraphQLSchema, Kind } from 'graphql'; import type { DocumentNode, GraphQLSchema, Kind } from 'graphql';
import type { import type { SchemaChangeType, SchemaCheck, SchemaCheckApprovalMetadata } from '@hive/storage';
SchemaChangeType,
SchemaCheck,
SchemaCheckApprovalMetadata,
SchemaVersion,
} from '@hive/storage';
import type { SchemaError } from '../../__generated__/types'; import type { SchemaError } from '../../__generated__/types';
import type { DateRange, PushedCompositeSchema, SingleSchema } from '../../shared/entities'; import type { DateRange, PushedCompositeSchema, SingleSchema } from '../../shared/entities';
import type { PromiseOrValue } from '../../shared/helpers'; import type { PromiseOrValue } from '../../shared/helpers';
@ -16,6 +11,7 @@ import type {
PaginatedContractConnection, PaginatedContractConnection,
} from './providers/contracts'; } from './providers/contracts';
import type { SchemaCheckWarning } from './providers/models/shared'; import type { SchemaCheckWarning } from './providers/models/shared';
import type { SchemaVersion } from './providers/schema-version-store';
export type SchemaChangeConnectionMapper = ReadonlyArray<SchemaChangeMapper>; export type SchemaChangeConnectionMapper = ReadonlyArray<SchemaChangeMapper>;
export type SchemaChangeMapper = SchemaChangeType; export type SchemaChangeMapper = SchemaChangeType;

View file

@ -1,5 +1,5 @@
import { Injectable, Scope } from 'graphql-modules'; import { Injectable, Scope } from 'graphql-modules';
import type { SchemaCheck, SchemaVersion } from '@hive/storage'; import type { SchemaCheck } from '@hive/storage';
import * as GraphQLSchema from '../../../__generated__/types'; import * as GraphQLSchema from '../../../__generated__/types';
import type { Target } from '../../../shared/entities'; import type { Target } from '../../../shared/entities';
import { cache } from '../../../shared/helpers'; import { cache } from '../../../shared/helpers';
@ -15,6 +15,7 @@ import {
type ContractVersion, type ContractVersion,
type CreateContractInput, type CreateContractInput,
} from './contracts'; } from './contracts';
import type { SchemaVersion } from './schema-version-store';
@Injectable({ @Injectable({
scope: Scope.Operation, scope: Scope.Operation,

View file

@ -3,9 +3,9 @@ import type {
SuccessfulSchemaCheckMapper, SuccessfulSchemaCheckMapper,
} from '../module.graphql.mappers'; } from '../module.graphql.mappers';
import { Injectable, Scope } from 'graphql-modules'; import { Injectable, Scope } from 'graphql-modules';
import { Storage } from '../../shared/providers/storage';
import { formatNumber } from '../lib/number-formatting'; import { formatNumber } from '../lib/number-formatting';
import { SchemaManager } from './schema-manager'; import { SchemaManager } from './schema-manager';
import { SchemaVersionStore } from './schema-version-store';
type SchemaCheck = FailedSchemaCheckMapper | SuccessfulSchemaCheckMapper; type SchemaCheck = FailedSchemaCheckMapper | SuccessfulSchemaCheckMapper;
@ -16,7 +16,7 @@ type SchemaCheck = FailedSchemaCheckMapper | SuccessfulSchemaCheckMapper;
export class SchemaCheckManager { export class SchemaCheckManager {
constructor( constructor(
private schemaManager: SchemaManager, private schemaManager: SchemaManager,
private storage: Storage, private schemaVersions: SchemaVersionStore,
) {} ) {}
getHasSchemaCompositionErrors(schemaCheck: SchemaCheck) { getHasSchemaCompositionErrors(schemaCheck: SchemaCheck) {
@ -63,7 +63,7 @@ export class SchemaCheckManager {
if (schemaCheck.schemaVersionId === null) { if (schemaCheck.schemaVersionId === null) {
return null; return null;
} }
return this.schemaManager.getSchemaVersion({ return this.schemaManager.getSchemaVersionBySelector({
organizationId: schemaCheck.selector.organizationId, organizationId: schemaCheck.selector.organizationId,
projectId: schemaCheck.selector.projectId, projectId: schemaCheck.selector.projectId,
targetId: schemaCheck.targetId, targetId: schemaCheck.targetId,
@ -76,10 +76,10 @@ export class SchemaCheckManager {
return null; return null;
} }
const service = await this.storage.getSchemaByNameOfVersion({ const service = await this.schemaVersions.getSchemaForSchemaVersionIdAndName(
versionId: schemaCheck.schemaVersionId, schemaCheck.schemaVersionId,
serviceName: schemaCheck.serviceName, schemaCheck.serviceName,
}); );
return service?.sdl ?? null; return service?.sdl ?? null;
} }

View file

@ -43,9 +43,9 @@ import { TargetManager } from '../../target/providers/target-manager';
import { BreakingSchemaChangeUsageHelper } from './breaking-schema-changes-helper'; import { BreakingSchemaChangeUsageHelper } from './breaking-schema-changes-helper';
import { SCHEMA_MODULE_CONFIG, type SchemaModuleConfig } from './config'; import { SCHEMA_MODULE_CONFIG, type SchemaModuleConfig } from './config';
import { Contracts } from './contracts'; import { Contracts } from './contracts';
import type { SchemaCoordinatesDiffResult } from './inspector';
import { CompositionOrchestrator } from './orchestrator/composition-orchestrator'; import { CompositionOrchestrator } from './orchestrator/composition-orchestrator';
import { ensureCompositeSchemas, removeDescriptions, SchemaHelper } from './schema-helper'; import { ensureCompositeSchemas, removeDescriptions, SchemaHelper } from './schema-helper';
import { SchemaVersionStore } from './schema-version-store';
const ENABLE_EXTERNAL_COMPOSITION_SCHEMA = z.object({ const ENABLE_EXTERNAL_COMPOSITION_SCHEMA = z.object({
endpoint: z.string().url().nonempty(), endpoint: z.string().url().nonempty(),
@ -84,6 +84,7 @@ export class SchemaManager {
private contracts: Contracts, private contracts: Contracts,
private breakingSchemaChangeUsageHelper: BreakingSchemaChangeUsageHelper, private breakingSchemaChangeUsageHelper: BreakingSchemaChangeUsageHelper,
private idTranslator: IdTranslator, private idTranslator: IdTranslator,
private schemaVersions: SchemaVersionStore,
@Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig, @Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig,
) { ) {
this.logger = logger.child({ source: 'SchemaManager' }); this.logger = logger.child({ source: 'SchemaManager' });
@ -92,7 +93,9 @@ export class SchemaManager {
return Promise.all( return Promise.all(
selectors.map(async selector => { selectors.map(async selector => {
return { return {
...(await this.storage.getLatestValidVersion(selector)), ...(await this.schemaVersions.getLatestValidSchemaVersionForTargetId(
selector.targetId,
)),
projectId: selector.projectId, projectId: selector.projectId,
targetId: selector.targetId, targetId: selector.targetId,
organizationId: selector.organizationId, organizationId: selector.organizationId,
@ -110,11 +113,7 @@ export class SchemaManager {
async hasSchema(target: Target) { async hasSchema(target: Target) {
this.logger.debug('Checking if schema is available (targetId=%s)', target.id); this.logger.debug('Checking if schema is available (targetId=%s)', target.id);
return this.storage.hasSchema({ return this.schemaVersions.anyVersionExistsForTarget(target);
organizationId: target.orgId,
projectId: target.projectId,
targetId: target.id,
});
} }
@traceFn('SchemaManager.compose', { @traceFn('SchemaManager.compose', {
@ -255,7 +254,7 @@ export class SchemaManager {
} & TargetSelector, } & TargetSelector,
) { ) {
this.logger.debug('Fetching non-empty list of schemas (selector=%o)', selector); this.logger.debug('Fetching non-empty list of schemas (selector=%o)', selector);
const schemas = await this.storage.getSchemasOfVersion(selector); const schemas = await this.schemaVersions.getSchemasBySchemaVersionId(selector.versionId);
if (schemas.length === 0) { if (schemas.length === 0) {
throw new HiveError('No schemas found for this version.'); throw new HiveError('No schemas found for this version.');
@ -275,19 +274,17 @@ export class SchemaManager {
@atomic(stringifySelector) @atomic(stringifySelector)
async getMaybeSchemasOfVersion(schemaVersion: SchemaVersion) { async getMaybeSchemasOfVersion(schemaVersion: SchemaVersion) {
this.logger.debug('Fetching schemas (schemaVersionId=%s)', schemaVersion.id); this.logger.debug('Fetching schemas (schemaVersionId=%s)', schemaVersion.id);
return this.storage.getSchemasOfVersion({ versionId: schemaVersion.id }); return this.schemaVersions.getSchemasBySchemaVersionId(schemaVersion.id);
} }
async getMatchingServiceSchemaOfVersions(versions: { before: string | null; after: string }) { async getMatchingServiceSchemaOfVersions(versions: { before: string | null; after: string }) {
this.logger.debug('Fetching service schema of versions (selector=%o)', versions); this.logger.debug('Fetching service schema of versions (selector=%o)', versions);
return this.storage.getMatchingServiceSchemaOfVersions(versions); return this.schemaVersions.getMatchingServiceSchemaOfVersions(versions);
} }
async getMaybeLatestValidVersion(target: Target) { async getMaybeLatestValidVersion(target: Target) {
this.logger.debug('Fetching maybe latest valid version (targetId=%o)', target.id); this.logger.debug('Fetching maybe latest valid version (targetId=%o)', target.id);
const version = await this.storage.getMaybeLatestValidVersion({ const version = await this.schemaVersions.getMaybeLatestValidSchemaVersion(target);
targetId: target.id,
});
if (!version) { if (!version) {
return null; return null;
@ -308,11 +305,7 @@ export class SchemaManager {
async getMaybeLatestVersion(target: Target) { async getMaybeLatestVersion(target: Target) {
this.logger.debug('Fetching maybe latest version (targetId=%o)', target.id); this.logger.debug('Fetching maybe latest version (targetId=%o)', target.id);
const latest = await this.storage.getMaybeLatestVersion({ const latest = await this.schemaVersions.getMaybeLatestSchemaVersionForTargetId(target.id);
targetId: target.id,
projectId: target.projectId,
organizationId: target.orgId,
});
if (!latest) { if (!latest) {
return null; return null;
@ -326,7 +319,7 @@ export class SchemaManager {
}; };
} }
async getSchemaVersion( async getSchemaVersionBySelector(
selector: TargetSelector & { versionId: string }, selector: TargetSelector & { versionId: string },
): Promise<SchemaVersion | null> { ): Promise<SchemaVersion | null> {
this.logger.debug('Fetching single schema version (selector=%o)', selector); this.logger.debug('Fetching single schema version (selector=%o)', selector);
@ -336,17 +329,20 @@ export class SchemaManager {
return null; return null;
} }
const result = await this.storage.getMaybeVersion(selector); const version = await this.schemaVersions.getSchemaVersionById(selector.versionId);
if (!result) { if (!version) {
return null;
}
if (version.targetId !== selector.targetId) {
return null; return null;
} }
return { return {
projectId: selector.projectId, projectId: selector.projectId,
targetId: selector.targetId,
organizationId: selector.organizationId, organizationId: selector.organizationId,
...result, ...version,
}; };
} }
@ -362,10 +358,7 @@ export class SchemaManager {
return null; return null;
} }
const schemas = await this.storage.getSchemasOfVersion({ const schemas = await this.schemaVersions.getSchemasBySchemaVersionId(schemaVersion.id);
versionId: schemaVersion.id,
includeMetadata: true,
});
return { return {
version: schemaVersion, version: schemaVersion,
@ -373,14 +366,14 @@ export class SchemaManager {
}; };
} }
async getPaginatedSchemaVersionsForTargetId(args: { async getPaginatedSchemaVersionsForTargetId(
targetId: string; target: Target,
organizationId: string; args: {
projectId: string;
first: number | null; first: number | null;
cursor: null | string; cursor: null | string;
}) { },
const connection = await this.storage.getPaginatedSchemaVersionsForTargetId(args); ) {
const connection = await this.schemaVersions.getPaginatedSchemaVersionsForTarget(target, args);
return { return {
...connection, ...connection,
@ -388,9 +381,9 @@ export class SchemaManager {
...edge, ...edge,
node: { node: {
...edge.node, ...edge.node,
organizationId: args.organizationId, organizationId: target.orgId,
projectId: args.projectId, projectId: target.projectId,
targetId: args.targetId, targetId: target.id,
}, },
})), })),
}; };
@ -411,7 +404,7 @@ export class SchemaManager {
async getSchemaLog(selector: { commit: string } & TargetSelector) { async getSchemaLog(selector: { commit: string } & TargetSelector) {
this.logger.debug('Fetching schema log (selector=%o)', selector); this.logger.debug('Fetching schema log (selector=%o)', selector);
return this.storage.getSchemaLog({ return this.schemaVersions.getSchemLog({
commit: selector.commit, commit: selector.commit,
targetId: selector.targetId, targetId: selector.targetId,
}); });
@ -438,10 +431,8 @@ export class SchemaManager {
url?: string | null; url?: string | null;
base_schema: string | null; base_schema: string | null;
metadata: string | null; metadata: string | null;
projectType: ProjectType;
actionFn(versionId: string): Promise<void>; actionFn(versionId: string): Promise<void>;
changes: Array<SchemaChangeType>; changes: Array<SchemaChangeType>;
coordinatesDiff: SchemaCoordinatesDiffResult | null;
previousSchemaVersion: string | null; previousSchemaVersion: string | null;
diffSchemaVersionId: string | null; diffSchemaVersionId: string | null;
github: null | { github: null | {
@ -489,7 +480,6 @@ export class SchemaManager {
'service', 'service',
'logIds', 'logIds',
'url', 'url',
'projectType',
'previousSchemaVersion', 'previousSchemaVersion',
'diffSchemaVersionId', 'diffSchemaVersionId',
'github', 'github',
@ -497,7 +487,7 @@ export class SchemaManager {
]), ]),
); );
return this.storage.createVersion({ return this.schemaVersions.createSchemaVersion({
...input, ...input,
logIds: input.logIds, logIds: input.logIds,
}); });
@ -589,22 +579,14 @@ export class SchemaManager {
await this.storage.updateBaseSchema(selector, newBaseSchema); await this.storage.updateBaseSchema(selector, newBaseSchema);
} }
countSchemaVersionsOfProject( countSchemaVersionsOfProject(project: Project, period: DateRange | null): Promise<number> {
selector: ProjectSelector & { this.logger.debug('Fetching schema versions count of project (projectId=%s)', project.id);
period: DateRange | null; return this.schemaVersions.countSchemaVersionsOfProject(project, period);
},
): Promise<number> {
this.logger.debug('Fetching schema versions count of project (selector=%o)', selector);
return this.storage.countSchemaVersionsOfProject(selector);
} }
countSchemaVersionsOfTarget( countSchemaVersionsOfTarget(target: Target, period: DateRange | null): Promise<number> {
selector: TargetSelector & { this.logger.debug('Fetching schema versions count of target (targetId=%s)', target.id);
period: DateRange | null; return this.schemaVersions.countSchemaVersionsOfTarget(target, period);
},
): Promise<number> {
this.logger.debug('Fetching schema versions count of target (selector=%o)', selector);
return this.storage.countSchemaVersionsOfTarget(selector);
} }
async completeGetStartedCheck( async completeGetStartedCheck(
@ -1084,11 +1066,8 @@ export class SchemaManager {
}, },
}); });
const record = await this.storage.getSchemaVersionByCommit({ const target = await this.targetManager.getTargetById({ targetId: selector.targetId });
projectId: selector.projectId, const record = await this.schemaVersions.getSchemaVersionForTargetByCommit(target, args.commit);
targetId: selector.targetId,
commit: args.commit,
});
if (!record) { if (!record) {
return null; return null;
@ -1102,47 +1081,31 @@ export class SchemaManager {
}; };
} }
async getComposableVersionBeforeVersionId(args: { async getComposableVersionBeforeVersionId(schemaVersion: SchemaVersion) {
organization: string; this.logger.debug('Fetch version before version id. (schemaVersionId=%s)', schemaVersion.id);
project: string;
target: string;
beforeVersionId: string;
beforeVersionCreatedAt: string;
}) {
this.logger.debug('Fetch version before version id. (args=%o)', args);
const schemaVersion = await this.storage.getVersionBeforeVersionId({ const previousSchemaVersion = await this.schemaVersions.getSchemaVersionBeforeSchemaVersion(
targetId: args.target, schemaVersion,
beforeVersionId: args.beforeVersionId, true,
beforeVersionCreatedAt: args.beforeVersionCreatedAt, );
onlyComposable: true,
});
if (!schemaVersion) { if (!previousSchemaVersion) {
return null; return null;
} }
return { return {
...schemaVersion, ...previousSchemaVersion,
organizationId: args.organization, organizationId: schemaVersion.organizationId,
projectId: args.project, projectId: schemaVersion.projectId,
targetId: args.target, targetId: schemaVersion.targetId,
}; };
} }
async getFirstComposableSchemaVersionBeforeVersionId(args: { async getFirstComposableSchemaVersionBeforeSchemaVersion(previousSchemaVersion: SchemaVersion) {
organization: string; const schemaVersion = await this.schemaVersions.getSchemaVersionBeforeSchemaVersion(
project: string; previousSchemaVersion,
target: string; true,
beforeVersionId: string; );
beforeVersionCreatedAt: string;
}) {
const schemaVersion = await this.storage.getVersionBeforeVersionId({
targetId: args.target,
beforeVersionId: args.beforeVersionId,
beforeVersionCreatedAt: args.beforeVersionCreatedAt,
onlyComposable: true,
});
if (!schemaVersion) { if (!schemaVersion) {
return null; return null;
@ -1150,9 +1113,9 @@ export class SchemaManager {
return { return {
...schemaVersion, ...schemaVersion,
organizationId: args.organization, organizationId: previousSchemaVersion.organizationId,
projectId: args.project, projectId: previousSchemaVersion.projectId,
targetId: args.target, targetId: previousSchemaVersion.targetId,
}; };
} }

View file

@ -63,6 +63,7 @@ import {
} from './schema-helper'; } from './schema-helper';
import { SchemaManager } from './schema-manager'; import { SchemaManager } from './schema-manager';
import { SchemaVersionHelper } from './schema-version-helper'; import { SchemaVersionHelper } from './schema-version-helper';
import { SchemaVersionStore } from './schema-version-store';
const schemaCheckCount = new promClient.Counter({ const schemaCheckCount = new promClient.Counter({
name: 'registry_check_count', name: 'registry_check_count',
@ -165,6 +166,7 @@ export class SchemaPublisher {
private schemaVersionHelper: SchemaVersionHelper, private schemaVersionHelper: SchemaVersionHelper,
private operationsReader: OperationsReader, private operationsReader: OperationsReader,
private idTranslator: IdTranslator, private idTranslator: IdTranslator,
private schemaVersions: SchemaVersionStore,
@Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig, @Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig,
singleModel: SingleModel, singleModel: SingleModel,
compositeModel: CompositeModel, compositeModel: CompositeModel,
@ -1493,15 +1495,11 @@ export class SchemaPublisher {
if (deleteResult.conclusion === SchemaDeleteConclusion.Accept) { if (deleteResult.conclusion === SchemaDeleteConclusion.Accept) {
this.logger.debug('Delete accepted'); this.logger.debug('Delete accepted');
if (input.dryRun !== true) { if (input.dryRun !== true) {
const schemaVersion = await this.storage.deleteSchema({ const schemaVersion = await this.schemaVersions.deleteSubgraphFromTarget(target, {
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
serviceName: input.serviceName, serviceName: input.serviceName,
composable: deleteResult.state.composable, composable: deleteResult.state.composable,
diffSchemaVersionId: latestComposableVersion?.version.id ?? null, diffSchemaVersionId: latestComposableVersion?.version.id ?? null,
changes: deleteResult.state.changes, changes: deleteResult.state.changes,
coordinatesDiff: deleteResult.state.coordinatesDiff,
contracts: contracts:
deleteResult.state.contracts?.map(contract => ({ deleteResult.state.contracts?.map(contract => ({
contractId: contract.contractId, contractId: contract.contractId,
@ -2027,7 +2025,6 @@ export class SchemaPublisher {
url: serviceUrl, url: serviceUrl,
base_schema: baseSchema, base_schema: baseSchema,
metadata: input.metadata ?? null, metadata: input.metadata ?? null,
projectType: project.type,
github, github,
actionFn: async (versionId: string) => { actionFn: async (versionId: string) => {
if (composable && fullSchemaSdl) { if (composable && fullSchemaSdl) {
@ -2054,7 +2051,6 @@ export class SchemaPublisher {
} }
}, },
changes, changes,
coordinatesDiff: publishResult.state.coordinatesDiff,
diffSchemaVersionId: latestComposable?.version.id ?? null, diffSchemaVersionId: latestComposable?.version.id ?? null,
previousSchemaVersion: latestVersion?.version.id ?? null, previousSchemaVersion: latestVersion?.version.id ?? null,
conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({ conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({

View file

@ -19,6 +19,7 @@ import { CompositionOrchestrator } from './orchestrator/composition-orchestrator
import { RegistryChecks } from './registry-checks'; import { RegistryChecks } from './registry-checks';
import { ensureCompositeSchemas, SchemaHelper, toCompositeSchemaInput } from './schema-helper'; import { ensureCompositeSchemas, SchemaHelper, toCompositeSchemaInput } from './schema-helper';
import { SchemaManager } from './schema-manager'; import { SchemaManager } from './schema-manager';
import { SchemaVersionStore } from './schema-version-store';
@Injectable({ @Injectable({
scope: Scope.Operation, scope: Scope.Operation,
@ -38,6 +39,7 @@ export class SchemaVersionHelper {
private storage: Storage, private storage: Storage,
private logger: Logger, private logger: Logger,
private compositionOrchestrator: CompositionOrchestrator, private compositionOrchestrator: CompositionOrchestrator,
private schemaVersions: SchemaVersionStore,
) {} ) {}
@traceFn('SchemaVersionHelper.composeSchemaVersion', { @traceFn('SchemaVersionHelper.composeSchemaVersion', {
@ -51,9 +53,7 @@ export class SchemaVersionHelper {
@cache<SchemaVersion>(version => version.id) @cache<SchemaVersion>(version => version.id)
private async composeSchemaVersion(schemaVersion: SchemaVersion) { private async composeSchemaVersion(schemaVersion: SchemaVersion) {
const [schemas, project, organization] = await Promise.all([ const [schemas, project, organization] = await Promise.all([
this.storage.getSchemasOfVersion({ this.schemaVersions.getSchemasBySchemaVersionId(schemaVersion.id),
versionId: schemaVersion.id,
}),
this.projectManager.getProject({ this.projectManager.getProject({
organizationId: schemaVersion.organizationId, organizationId: schemaVersion.organizationId,
projectId: schemaVersion.projectId, projectId: schemaVersion.projectId,
@ -186,11 +186,8 @@ export class SchemaVersionHelper {
} }
if (schemaVersion.hasPersistedSchemaChanges) { if (schemaVersion.hasPersistedSchemaChanges) {
const changes: null | Array<SchemaChangeType> = await this.storage.getSchemaChangesForVersion( const changes: null | Array<SchemaChangeType> =
{ await this.schemaVersions.getSchemaSchangesForSchemaVersion(schemaVersion);
versionId: schemaVersion.id,
},
);
const safeChanges: Array<SchemaChangeType> = []; const safeChanges: Array<SchemaChangeType> = [];
const breakingChanges: Array<SchemaChangeType> = []; const breakingChanges: Array<SchemaChangeType> = [];
@ -220,12 +217,8 @@ export class SchemaVersionHelper {
const incomingSdl = await this.getCompositeSchemaSdl(schemaVersion); const incomingSdl = await this.getCompositeSchemaSdl(schemaVersion);
const [schemaBefore, schemasAfter] = await Promise.all([ const [schemaBefore, schemasAfter] = await Promise.all([
this.storage.getSchemasOfVersion({ this.schemaVersions.getSchemasBySchemaVersionId(schemaVersion.id),
versionId: schemaVersion.id, this.schemaVersions.getSchemasBySchemaVersionId(previousVersion.id),
}),
this.storage.getSchemasOfVersion({
versionId: previousVersion.id,
}),
]); ]);
if (!existingSdl || !incomingSdl) { if (!existingSdl || !incomingSdl) {
@ -271,7 +264,7 @@ export class SchemaVersionHelper {
): Promise<SchemaVersion | null> { ): Promise<SchemaVersion | null> {
if (schemaVersion.recordVersion === '2024-01-10') { if (schemaVersion.recordVersion === '2024-01-10') {
if (schemaVersion.diffSchemaVersionId) { if (schemaVersion.diffSchemaVersionId) {
return await this.schemaManager.getSchemaVersion({ return await this.schemaManager.getSchemaVersionBySelector({
organizationId: schemaVersion.organizationId, organizationId: schemaVersion.organizationId,
projectId: schemaVersion.projectId, projectId: schemaVersion.projectId,
targetId: schemaVersion.targetId, targetId: schemaVersion.targetId,
@ -281,13 +274,7 @@ export class SchemaVersionHelper {
return null; return null;
} }
return await this.schemaManager.getComposableVersionBeforeVersionId({ return await this.schemaManager.getComposableVersionBeforeVersionId(schemaVersion);
organization: schemaVersion.organizationId,
project: schemaVersion.projectId,
target: schemaVersion.targetId,
beforeVersionId: schemaVersion.id,
beforeVersionCreatedAt: schemaVersion.createdAt,
});
} }
async getBreakingSchemaChanges(schemaVersion: SchemaVersion) { async getBreakingSchemaChanges(schemaVersion: SchemaVersion) {
@ -327,13 +314,7 @@ export class SchemaVersionHelper {
} }
const composableVersion = const composableVersion =
await this.schemaManager.getFirstComposableSchemaVersionBeforeVersionId({ await this.schemaManager.getFirstComposableSchemaVersionBeforeSchemaVersion(schemaVersion);
organization: schemaVersion.organizationId,
project: schemaVersion.projectId,
target: schemaVersion.targetId,
beforeVersionId: schemaVersion.id,
beforeVersionCreatedAt: schemaVersion.createdAt,
});
return !composableVersion; return !composableVersion;
} }
@ -353,10 +334,10 @@ export class SchemaVersionHelper {
return null; return null;
} }
const schemaLog = await this.storage.getServiceSchemaOfVersion({ const schemaLog = await this.schemaVersions.getServiceSchemaOfVersion(
schemaVersionId: previousVersion.id, schemaVersion,
serviceName, serviceName,
}); );
return schemaLog?.sdl ?? null; return schemaLog?.sdl ?? null;
} }

File diff suppressed because it is too large Load diff

View file

@ -19,11 +19,9 @@ export const Project: Pick<
return null; return null;
}, },
schemaVersionsCount: (project, { period }, { injector }) => { schemaVersionsCount: (project, { period }, { injector }) => {
return injector.get(SchemaManager).countSchemaVersionsOfProject({ return injector
organizationId: project.orgId, .get(SchemaManager)
projectId: project.id, .countSchemaVersionsOfProject(project, period ? parseDateRangeInput(period) : null);
period: period ? parseDateRangeInput(period) : null,
});
}, },
isNativeFederationEnabled: project => { isNativeFederationEnabled: project => {
return project.nativeFederation === true; return project.nativeFederation === true;

View file

@ -2,6 +2,7 @@ import { parseDateRangeInput } from '../../../shared/helpers';
import { OperationsManager } from '../../operations/providers/operations-manager'; import { OperationsManager } from '../../operations/providers/operations-manager';
import { ContractsManager } from '../providers/contracts-manager'; import { ContractsManager } from '../providers/contracts-manager';
import { SchemaManager } from '../providers/schema-manager'; import { SchemaManager } from '../providers/schema-manager';
import { SchemaVersionStore } from '../providers/schema-version-store';
import { toGraphQLSchemaCheck, toGraphQLSchemaCheckCurry } from '../to-graphql-schema-check'; import { toGraphQLSchemaCheck, toGraphQLSchemaCheckCurry } from '../to-graphql-schema-check';
import type { TargetResolvers } from './../../../__generated__/types'; import type { TargetResolvers } from './../../../__generated__/types';
@ -21,32 +22,18 @@ export const Target: Pick<
| 'schemaVersionsCount' | 'schemaVersionsCount'
> = { > = {
schemaVersions: async (target, args, { injector }) => { schemaVersions: async (target, args, { injector }) => {
return injector.get(SchemaManager).getPaginatedSchemaVersionsForTargetId({ return injector.get(SchemaManager).getPaginatedSchemaVersionsForTargetId(target, {
targetId: target.id,
organizationId: target.orgId,
projectId: target.projectId,
cursor: args.after ?? null, cursor: args.after ?? null,
first: args.first ?? null, first: args.first ?? null,
}); });
}, },
schemaVersion: async (target, args, { injector }) => { schemaVersion: async (target, args, { injector }) => {
const schemaVersion = await injector.get(SchemaManager).getSchemaVersion({ return await injector.get(SchemaManager).getSchemaVersionBySelector({
organizationId: target.orgId, organizationId: target.orgId,
projectId: target.projectId, projectId: target.projectId,
targetId: target.id, targetId: target.id,
versionId: args.id, versionId: args.id,
}); });
if (schemaVersion === null) {
return null;
}
return {
...schemaVersion,
organizationId: target.orgId,
projectId: target.projectId,
targetId: target.id,
};
}, },
latestSchemaVersion: (target, _, { injector }) => { latestSchemaVersion: (target, _, { injector }) => {
return injector.get(SchemaManager).getMaybeLatestVersion(target); return injector.get(SchemaManager).getMaybeLatestVersion(target);
@ -58,7 +45,7 @@ export const Target: Pick<
return injector.get(SchemaManager).getBaseSchemaForTarget(target); return injector.get(SchemaManager).getBaseSchemaForTarget(target);
}, },
hasSchema: (target, _, { injector }) => { hasSchema: (target, _, { injector }) => {
return injector.get(SchemaManager).hasSchema(target); return injector.get(SchemaVersionStore).anyVersionExistsForTarget(target);
}, },
schemaCheck: async (target, args, { injector }) => { schemaCheck: async (target, args, { injector }) => {
const schemaCheck = await injector.get(SchemaManager).findSchemaCheckForTarget(target, args.id); const schemaCheck = await injector.get(SchemaManager).findSchemaCheckForTarget(target, args.id);
@ -92,12 +79,9 @@ export const Target: Pick<
}; };
}, },
schemaVersionsCount: (target, { period }, { injector }) => { schemaVersionsCount: (target, { period }, { injector }) => {
return injector.get(SchemaManager).countSchemaVersionsOfTarget({ return injector
organizationId: target.orgId, .get(SchemaManager)
projectId: target.projectId, .countSchemaVersionsOfTarget(target, period ? parseDateRangeInput(period) : null);
targetId: target.id,
period: period ? parseDateRangeInput(period) : null,
});
}, },
contracts: async (target, args, { injector }) => { contracts: async (target, args, { injector }) => {
return await injector.get(ContractsManager).getPaginatedContractsForTarget({ return await injector.get(ContractsManager).getPaginatedContractsForTarget({

View file

@ -2,14 +2,10 @@ import { Injectable } from 'graphql-modules';
import type { PolicyConfigurationObject } from '@hive/policy'; import type { PolicyConfigurationObject } from '@hive/policy';
import { PostgresDatabasePool } from '@hive/postgres'; import { PostgresDatabasePool } from '@hive/postgres';
import type { import type {
ConditionalBreakingChangeMetadata,
PaginatedOrganizationInvitationConnection, PaginatedOrganizationInvitationConnection,
PaginatedSchemaVersionConnection,
SchemaChangeType, SchemaChangeType,
SchemaCheck, SchemaCheck,
SchemaCheckInput, SchemaCheckInput,
SchemaCompositionError,
SchemaVersion,
TargetBreadcrumb, TargetBreadcrumb,
} from '@hive/storage'; } from '@hive/storage';
import type { SchemaChecksFilter } from '../../../__generated__/types'; import type { SchemaChecksFilter } from '../../../__generated__/types';
@ -17,7 +13,6 @@ import type {
Alert, Alert,
AlertChannel, AlertChannel,
CDNAccessToken, CDNAccessToken,
DeletedCompositeSchema,
DocumentCollection, DocumentCollection,
DocumentCollectionOperation, DocumentCollectionOperation,
Member, Member,
@ -28,8 +23,6 @@ import type {
PaginatedDocumentCollectionOperations, PaginatedDocumentCollectionOperations,
PaginatedDocumentCollections, PaginatedDocumentCollections,
Project, Project,
Schema,
SchemaLog,
SchemaPolicy, SchemaPolicy,
Target, Target,
TargetSettings, TargetSettings,
@ -42,7 +35,6 @@ import type {
} from '../../auth/providers/scopes'; } from '../../auth/providers/scopes';
import type { ResourceAssignmentGroup } from '../../organization/lib/resource-assignment-model'; import type { ResourceAssignmentGroup } from '../../organization/lib/resource-assignment-model';
import type { Contracts } from '../../schema/providers/contracts'; import type { Contracts } from '../../schema/providers/contracts';
import type { SchemaCoordinatesDiffResult } from '../../schema/providers/inspector';
export interface OrganizationSelector { export interface OrganizationSelector {
organizationId: string; organizationId: string;
@ -56,15 +48,6 @@ export interface TargetSelector extends ProjectSelector {
targetId: string; targetId: string;
} }
type CreateContractVersionInput = {
contractId: string;
contractName: string;
compositeSchemaSDL: string | null;
supergraphSDL: string | null;
schemaCompositionErrors: Array<SchemaCompositionError> | null;
changes: null | Array<SchemaChangeType>;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface Storage { export interface Storage {
pool: PostgresDatabasePool; pool: PostgresDatabasePool;
@ -351,69 +334,6 @@ export interface Storage {
_: Pick<TargetSelector, 'targetId' | 'projectId'> & _: Pick<TargetSelector, 'targetId' | 'projectId'> &
Partial<TargetSettings['appDeploymentProtection']>, Partial<TargetSettings['appDeploymentProtection']>,
): Promise<TargetSettings['appDeploymentProtection']>; ): Promise<TargetSettings['appDeploymentProtection']>;
countSchemaVersionsOfProject(
_: ProjectSelector & {
period: {
from: Date;
to: Date;
} | null;
},
): Promise<number>;
countSchemaVersionsOfTarget(
_: TargetSelector & {
period: {
from: Date;
to: Date;
} | null;
},
): Promise<number>;
hasSchema(_: TargetSelector): Promise<boolean>;
getLatestValidVersion(_: { targetId: string }): Promise<SchemaVersion | never>;
getMaybeLatestValidVersion(_: { targetId: string }): Promise<SchemaVersion | null | never>;
getMaybeLatestVersion(_: TargetSelector): Promise<SchemaVersion | null>;
/** Find the version before a schema version */
getVersionBeforeVersionId(_: {
targetId: string;
beforeVersionId: string;
beforeVersionCreatedAt: string;
onlyComposable: boolean;
}): Promise<SchemaVersion | null>;
/**
* Find a specific schema version via it's action id.
* The action id is the id of the action that created the schema version, it is user provided.
* Multiple entries with the same action ID can exist. In that case the latest one is returned.
*/
getSchemaVersionByCommit(_: {
targetId: string;
projectId: string;
commit: string;
}): Promise<SchemaVersion | null>;
getMatchingServiceSchemaOfVersions(versions: {
before: string | null;
after: string;
}): Promise<null | {
serviceName: string;
before: string | null;
after: string | null;
}>;
getSchemasOfVersion(_: { versionId: string; includeMetadata?: boolean }): Promise<Schema[]>;
getSchemaByNameOfVersion(_: { versionId: string; serviceName: string }): Promise<Schema | null>;
getServiceSchemaOfVersion(args: {
schemaVersionId: string;
serviceName: string;
}): Promise<Schema | null>;
getPaginatedSchemaVersionsForTargetId(args: {
targetId: string;
first: number | null;
cursor: null | string;
}): Promise<PaginatedSchemaVersionConnection>;
getPaginatedSchemaChecksForSchemaProposal< getPaginatedSchemaChecksForSchemaProposal<
TransformedSchemaCheck extends SchemaCheck = SchemaCheck, TransformedSchemaCheck extends SchemaCheck = SchemaCheck,
>(_: { >(_: {
@ -438,96 +358,6 @@ export interface Storage {
}>; }>;
}> }>
>; >;
getMaybeVersion(_: TargetSelector & { versionId: string }): Promise<SchemaVersion | null>;
deleteSchema(
_: {
serviceName: string;
composable: boolean;
actionFn(versionId: string): Promise<void>;
changes: Array<SchemaChangeType> | null;
diffSchemaVersionId: string | null;
conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata;
contracts: null | Array<CreateContractVersionInput>;
coordinatesDiff: SchemaCoordinatesDiffResult | null;
} & TargetSelector &
(
| {
compositeSchemaSDL: null;
supergraphSDL: null;
schemaCompositionErrors: Array<SchemaCompositionError>;
tags: null;
schemaMetadata: null;
metadataAttributes: null;
}
| {
compositeSchemaSDL: string;
supergraphSDL: string | null;
schemaCompositionErrors: null;
tags: null | Array<string>;
schemaMetadata: null | Record<
string,
Array<{ name: string; content: string; source: string | null }>
>;
metadataAttributes: null | Record<string, string[]>;
}
),
): Promise<DeletedCompositeSchema & { versionId: string }>;
createVersion(
_: ({
schema: string;
author: string;
service?: string | null;
metadata: string | null;
valid: boolean;
url?: string | null;
commit: string;
logIds: string[];
base_schema: string | null;
actionFn(versionId: string): Promise<void>;
changes: Array<SchemaChangeType>;
previousSchemaVersion: null | string;
diffSchemaVersionId: null | string;
github: null | {
repository: string;
sha: string;
};
contracts: null | Array<CreateContractVersionInput>;
conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata;
coordinatesDiff: SchemaCoordinatesDiffResult | null;
} & TargetSelector) &
(
| {
compositeSchemaSDL: null;
supergraphSDL: null;
schemaCompositionErrors: Array<SchemaCompositionError>;
tags: null;
schemaMetadata: null;
metadataAttributes: null;
}
| {
compositeSchemaSDL: string;
supergraphSDL: string | null;
schemaCompositionErrors: null;
tags: null | Array<string>;
schemaMetadata: null | Record<
string,
Array<{ name: string; content: string; source: string | null }>
>;
metadataAttributes: null | Record<string, string[]>;
}
),
): Promise<SchemaVersion | never>;
/**
* Returns the changes between the given version and the previous version.
* If it return `null` the schema version does not have any changes persisted.
* This can happen if the schema version was created before we introduced persisting changes.
*/
getSchemaChangesForVersion(_: { versionId: string }): Promise<null | Array<SchemaChangeType>>;
getSchemaLog(_: { commit: string; targetId: string }): Promise<SchemaLog>;
addSlackIntegration(_: OrganizationSelector & { token: string }): Promise<void>; addSlackIntegration(_: OrganizationSelector & { token: string }): Promise<void>;
deleteSlackIntegration(_: OrganizationSelector): Promise<void>; deleteSlackIntegration(_: OrganizationSelector): Promise<void>;
@ -881,4 +711,3 @@ export interface Storage {
@Injectable() @Injectable()
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class Storage implements Storage {} export class Storage implements Storage {}
export type { PaginatedSchemaVersionConnection };

View file

@ -3,12 +3,7 @@ import { DocumentNode, GraphQLError, parse, print, SourceLocation } from 'graphq
import { z } from 'zod'; import { z } from 'zod';
import type { AvailableRulesResponse, PolicyConfigurationObject } from '@hive/policy'; import type { AvailableRulesResponse, PolicyConfigurationObject } from '@hive/policy';
import type { CompositionFailureError } from '@hive/schema'; import type { CompositionFailureError } from '@hive/schema';
import type { import type { schema_policy_resource } from '@hive/storage';
CompositeDeletedSchemaLog,
CompositePushSchemaLog,
schema_policy_resource,
SinglePushSchemaLog,
} from '@hive/storage';
import type { import type {
AlertChannelType, AlertChannelType,
AlertType, AlertType,
@ -18,6 +13,11 @@ import type {
TargetAccessScope, TargetAccessScope,
} from '../__generated__/types'; } from '../__generated__/types';
import type { ResourceAssignmentGroup } from '../modules/organization/lib/resource-assignment-model'; import type { ResourceAssignmentGroup } from '../modules/organization/lib/resource-assignment-model';
import type {
CompositeDeletedSchemaLog,
CompositePushSchemaLog,
SinglePushSchemaLog,
} from '../modules/schema/providers/schema-version-store';
import { parseGraphQLSource, sortDocumentNode } from './schema'; import { parseGraphQLSource, sortDocumentNode } from './schema';
export const NameModel = z export const NameModel = z

View file

@ -0,0 +1,4 @@
CF_BROKER_SIGNATURE=dev-secret
PORT=4010
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0

View file

@ -9,6 +9,7 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "4.20250913.0", "@cloudflare/workers-types": "4.20250913.0",
"@hive/service-common": "workspace:*",
"@types/service-worker-mock": "2.0.4", "@types/service-worker-mock": "2.0.4",
"@whatwg-node/server": "0.10.17", "@whatwg-node/server": "0.10.17",
"esbuild": "0.25.9", "esbuild": "0.25.9",

View file

@ -1,5 +1,6 @@
import { createServer } from 'http'; import { createServer } from 'http';
import { Router } from 'itty-router'; import { Router } from 'itty-router';
import { resolveServerListenOptions } from '@hive/service-common/listen-options';
import { createServerAdapter } from '@whatwg-node/server'; import { createServerAdapter } from '@whatwg-node/server';
import { createSignatureValidator } from './auth'; import { createSignatureValidator } from './auth';
import { env } from './dev-polyfill'; import { env } from './dev-polyfill';
@ -7,6 +8,12 @@ import { handleRequest } from './handler';
// eslint-disable-next-line no-process-env // eslint-disable-next-line no-process-env
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4010; const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4010;
const listenOptions = resolveServerListenOptions({
// eslint-disable-next-line no-process-env
serverHost: process.env.SERVER_HOST,
// eslint-disable-next-line no-process-env
serverHostIpv6Only: process.env.SERVER_HOST_IPV6_ONLY === '1' ? '1' : '0',
});
const isSignatureValid = createSignatureValidator(env.SIGNATURE); const isSignatureValid = createSignatureValidator(env.SIGNATURE);
function main() { function main() {
@ -32,7 +39,14 @@ function main() {
const server = createServer(app); const server = createServer(app);
return new Promise<void>(resolve => { return new Promise<void>(resolve => {
server.listen(PORT, '::', resolve); server.listen(
{
port: PORT,
host: listenOptions.host,
ipv6Only: listenOptions.ipv6Only,
},
resolve,
);
}); });
} }

View file

@ -2,3 +2,6 @@ S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID="minioadmin" S3_ACCESS_KEY_ID="minioadmin"
S3_SECRET_ACCESS_KEY="minioadmin" S3_SECRET_ACCESS_KEY="minioadmin"
S3_BUCKET_NAME="artifacts" S3_BUCKET_NAME="artifacts"
PORT=4010
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0

View file

@ -18,6 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "4.20250913.0", "@cloudflare/workers-types": "4.20250913.0",
"@hive/service-common": "workspace:*",
"@types/service-worker-mock": "2.0.4", "@types/service-worker-mock": "2.0.4",
"@whatwg-node/server": "0.10.17", "@whatwg-node/server": "0.10.17",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",

View file

@ -61,7 +61,8 @@ type Event =
| 'GET cdn-legacy-keys' | 'GET cdn-legacy-keys'
| 'GET cdn-access-token' | 'GET cdn-access-token'
| 'GET persistedOperation' | 'GET persistedOperation'
| 'HEAD appDeploymentIsEnabled'; | 'HEAD appDeploymentIsEnabled'
| 'GET appDeploymentManifest';
// Either 3 digit status code or error code e.g. timeout, http error etc. // Either 3 digit status code or error code e.g. timeout, http error etc.
statusCodeOrErrCode: number | string; statusCodeOrErrCode: number | string;
/** duration in milliseconds */ /** duration in milliseconds */

View file

@ -69,6 +69,12 @@ const VersionedParamsModel = zod.object({
.transform(value => value ?? null), .transform(value => value ?? null),
}); });
export const AppDeploymentsManifestParamsModel = zod.object({
targetId: zod.string(),
appName: zod.string(),
appVersion: zod.string(),
});
const PersistedOperationParamsModel = zod.object({ const PersistedOperationParamsModel = zod.object({
targetId: zod.string(), targetId: zod.string(),
appName: zod.string(), appName: zod.string(),
@ -461,9 +467,6 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => {
const response = createResponse( const response = createResponse(
analytics, analytics,
result.body, result.body,
// We're using here a public location, because we expose the Location to the end user and
// the public S3 endpoint may differ from the internal S3 endpoint. E.g. within a docker network.
// If they are the same, private and public locations will be the same.
{ status: 200 }, { status: 200 },
params.targetId, params.targetId,
request, request,
@ -474,6 +477,70 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => {
} }
}, },
); );
router.get(
'/artifacts/v1/:targetId/apps/:appName/:appVersion',
async function AppDeploymentManifestHandler(request) {
const parseResult = AppDeploymentsManifestParamsModel.safeParse(request.params);
if (parseResult.success === false) {
analytics.track(
{ type: 'error', value: ['invalid-params'] },
request.params?.targetId ?? 'unknown',
);
return createResponse(
analytics,
'Not found.',
{
status: 404,
},
request.params?.targetId ?? 'unknown',
request,
);
}
const params = parseResult.data;
const maybeResponse = await authenticate(request, params.targetId);
if (maybeResponse !== null) {
return maybeResponse;
}
const manifest = await deps.artifactStorageReader.readAppDeploymentManifest(
params.targetId,
params.appName,
params.appVersion,
);
if (manifest === null) {
return createResponse(
analytics,
'Not found.',
{
status: 404,
},
request.params?.targetId ?? 'unknown',
request,
);
}
const response = createResponse(
analytics,
JSON.stringify(manifest),
{
status: 200,
headers: {
'content-type': 'application/json',
},
},
params.targetId,
request,
);
return response;
},
);
return async (request: Request, captureException?: (error: unknown) => void) => { return async (request: Request, captureException?: (error: unknown) => void) => {
return router.handle(request, captureException); return router.handle(request, captureException);

View file

@ -3,6 +3,14 @@ import type { Analytics } from './analytics';
import { AwsClient } from './aws'; import { AwsClient } from './aws';
import type { Breadcrumb } from './breadcrumbs'; import type { Breadcrumb } from './breadcrumbs';
export const AppDeploymentManifestModel = zod.object({
id: zod.string().uuid(),
appName: zod.string(),
appVersion: zod.string(),
isActive: zod.boolean(),
documentHashes: zod.array(zod.string()),
});
export function buildArtifactStorageKey( export function buildArtifactStorageKey(
targetId: string, targetId: string,
artifactType: string, artifactType: string,
@ -50,6 +58,7 @@ const AppDeploymentIsEnabledKeyModel = zod.tuple([
/** /**
* S3 key for determining whether app deployment is enabled or not. * S3 key for determining whether app deployment is enabled or not.
* Note: we validate to avoid invalid keys / collisions that could be caused by type errors. * Note: we validate to avoid invalid keys / collisions that could be caused by type errors.
*
**/ **/
export function buildAppDeploymentIsEnabledKey( export function buildAppDeploymentIsEnabledKey(
...args: [targetId: string, appName: string, appVersion: string] ...args: [targetId: string, appName: string, appVersion: string]
@ -57,6 +66,12 @@ export function buildAppDeploymentIsEnabledKey(
return ['apps-enabled', ...AppDeploymentIsEnabledKeyModel.parse(args)].join('/'); return ['apps-enabled', ...AppDeploymentIsEnabledKeyModel.parse(args)].join('/');
} }
export function buildAppDeploymentManifestKey(
...args: [targetId: string, appName: string, appVersion: string]
) {
return ['apps-manifest', ...AppDeploymentIsEnabledKeyModel.parse(args)].join('/');
}
/** /**
* Read an artifact/app deployment operation from S3. * Read an artifact/app deployment operation from S3.
*/ */
@ -353,6 +368,49 @@ export class ArtifactStorageReader {
return response.status === 200; return response.status === 200;
} }
async readAppDeploymentManifest(targetId: string, appName: string, appVersion: string) {
const key = buildAppDeploymentManifestKey(targetId, appName, appVersion);
const response = await this.request({
key,
method: 'GET',
onAttempt: args => {
if (args.result.type === 'error') {
this.breadcrumb(
`Fetch attempt failed (source=${args.isMirror ? 'mirror' : 'primary'}, attempt=${args.attempt} duration=${args.duration}, result=${args.result.type}, key=${key}, message=${args.result.error.message})`,
);
} else {
this.breadcrumb(
`Fetch attempt succeeded (source=${args.isMirror ? 'mirror' : 'primary'}, attempt=${args.attempt} duration=${args.duration}, result=${args.result.type}, key=${key})`,
);
}
this.analytics?.track(
{
type: args.isMirror ? 's3' : 'r2',
statusCodeOrErrCode:
args.result.type === 'error'
? String(args.result.error.name ?? 'unknown')
: args.result.response.status,
action: 'GET appDeploymentManifest',
duration: args.duration,
},
targetId,
);
},
});
if (response.status === 404) {
return null;
}
if (response.status !== 200) {
throw new Error('Failed to retrieve app deployment manifest');
}
const body = await response.json();
return AppDeploymentManifestModel.parse(body);
}
async loadAppDeploymentPersistedOperation( async loadAppDeploymentPersistedOperation(
targetId: string, targetId: string,
appName: string, appName: string,

View file

@ -1,5 +1,6 @@
import { createServer } from 'http'; import { createServer } from 'http';
import * as itty from 'itty-router'; import * as itty from 'itty-router';
import { resolveServerListenOptions } from '@hive/service-common/listen-options';
import { createServerAdapter } from '@whatwg-node/server'; import { createServerAdapter } from '@whatwg-node/server';
import { createArtifactRequestHandler } from './artifact-handler'; import { createArtifactRequestHandler } from './artifact-handler';
import { ArtifactStorageReader } from './artifact-storage-reader'; import { ArtifactStorageReader } from './artifact-storage-reader';
@ -22,6 +23,12 @@ const s3 = {
// eslint-disable-next-line no-process-env // eslint-disable-next-line no-process-env
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4010; const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4010;
const listenOptions = resolveServerListenOptions({
// eslint-disable-next-line no-process-env
serverHost: process.env.SERVER_HOST,
// eslint-disable-next-line no-process-env
serverHostIpv6Only: process.env.SERVER_HOST_IPV6_ONLY === '1' ? '1' : '0',
});
const artifactStorageReader = new ArtifactStorageReader(s3, null, null, null); const artifactStorageReader = new ArtifactStorageReader(s3, null, null, null);
@ -93,7 +100,14 @@ function main() {
const server = createServer(app); const server = createServer(app);
return new Promise<void>(resolve => { return new Promise<void>(resolve => {
server.listen(PORT, '::', resolve); server.listen(
{
port: PORT,
host: listenOptions.host,
ipv6Only: listenOptions.ipv6Only,
},
resolve,
);
}); });
} }

View file

@ -1,4 +1,6 @@
PORT=4013 PORT=4013
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0
OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>" OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>"
CLICKHOUSE_PROTOCOL="http" CLICKHOUSE_PROTOCOL="http"
CLICKHOUSE_HOST="localhost" CLICKHOUSE_HOST="localhost"

View file

@ -1,5 +1,5 @@
import zod from 'zod'; import zod from 'zod';
import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common';
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
@ -22,6 +22,8 @@ export const emptyString = <T extends zod.ZodType>(input: T) => {
const EnvironmentModel = zod.object({ const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString.optional()), PORT: emptyString(NumberFromString.optional()),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
ENVIRONMENT: emptyString(zod.string().optional()), ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()),
}); });
@ -141,6 +143,10 @@ export const env = {
release: base.RELEASE ?? 'local', release: base.RELEASE ?? 'local',
http: { http: {
port: base.PORT ?? 4012, port: base.PORT ?? 4012,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
}, },
tracing: { tracing: {
enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT,

View file

@ -137,11 +137,16 @@ async function main() {
}); });
if (env.prometheus) { if (env.prometheus) {
await startMetrics(env.prometheus.labels.instance, env.prometheus.port); await startMetrics(env.prometheus.labels.instance, {
port: env.prometheus.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
});
} }
await server.listen({ await server.listen({
port: env.http.port, port: env.http.port,
host: '::', host: env.http.host,
ipv6Only: env.http.ipv6Only,
}); });
await Promise.all([usageEstimator.start(), rateLimiter.start(), stripeBilling.start()]); await Promise.all([usageEstimator.start(), rateLimiter.start(), stripeBilling.start()]);
} catch (error) { } catch (error) {

View file

@ -1 +1,4 @@
SECRET=secretsecret SECRET=secretsecret
PORT=3069
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0

View file

@ -11,6 +11,7 @@
"@apollo/composition": "2.13.2", "@apollo/composition": "2.13.2",
"@apollo/federation-internals": "2.13.2", "@apollo/federation-internals": "2.13.2",
"@graphql-hive/external-composition": "workspace:*", "@graphql-hive/external-composition": "workspace:*",
"@hive/service-common": "workspace:*",
"@whatwg-node/server": "0.10.17", "@whatwg-node/server": "0.10.17",
"dotenv": "16.4.7", "dotenv": "16.4.7",
"graphql": "16.9.0", "graphql": "16.9.0",

View file

@ -0,0 +1,43 @@
import { resolveEnv } from './environment';
describe('resolveEnv', () => {
// eslint-disable-next-line no-restricted-syntax -- explicit IPv4 literal required for validation coverage
const ipv4Host = '0.0.0.0';
test('uses host and ipv6 defaults when not provided', () => {
const env = resolveEnv({
SECRET: 'secretsecret',
});
expect(env.http).toEqual({
port: 3069,
host: '::',
ipv6Only: false,
});
});
test('honors explicit host and ipv6-only values', () => {
const env = resolveEnv({
SECRET: 'secretsecret',
PORT: '4000',
SERVER_HOST: '::1',
SERVER_HOST_IPV6_ONLY: '1',
});
expect(env.http).toEqual({
port: 4000,
host: '::1',
ipv6Only: true,
});
});
test('rejects ipv6-only combined with an IPv4 literal host', () => {
expect(() =>
resolveEnv({
SECRET: 'secretsecret',
SERVER_HOST: ipv4Host,
SERVER_HOST_IPV6_ONLY: '1',
}),
).toThrow(/SERVER_HOST_IPV6_ONLY=1 is incompatible with IPv4 host "0\.0\.0\.0"/);
});
});

View file

@ -1,4 +1,5 @@
import zod from 'zod'; import zod from 'zod';
import { resolveServerListenOptions } from '@hive/service-common';
function extractConfig<Input, Output>(config: zod.SafeParseReturnType<Input, Output>): Output { function extractConfig<Input, Output>(config: zod.SafeParseReturnType<Input, Output>): Output {
if (!config.success) { if (!config.success) {
@ -15,6 +16,8 @@ const BaseSchema = zod.object({
.number() .number()
.transform(port => port || 3069) .transform(port => port || 3069)
.default(3069), .default(3069),
SERVER_HOST: zod.string().default('::'),
SERVER_HOST_IPV6_ONLY: zod.union([zod.literal('1'), zod.literal('0')]).default('0'),
SECRET: zod.string(), SECRET: zod.string(),
}); });
@ -44,6 +47,10 @@ export function resolveEnv(env: Record<string, string | undefined>) {
release: base.RELEASE ?? 'local', release: base.RELEASE ?? 'local',
http: { http: {
port: base.PORT, port: base.PORT,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
}, },
secret: base.SECRET, secret: base.SECRET,
}; };

View file

@ -7,9 +7,20 @@ import { createRequestListener } from './server';
const env = resolveEnv(process.env); const env = resolveEnv(process.env);
const server = createServer(createRequestListener(env)); const server = createServer(createRequestListener(env));
server.listen(env.http.port, '::', () => { function formatListenAddress(host: string, port: number) {
console.log(`Listening on http://localhost:${env.http.port}`); return host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`;
}); }
server.listen(
{
port: env.http.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
},
() => {
console.log(`Listening on ${formatListenAddress(env.http.host, env.http.port)}`);
},
);
process.on('SIGINT', () => { process.on('SIGINT', () => {
server.close(err => { server.close(err => {

View file

@ -1,3 +1,5 @@
ENVIRONMENT=development ENVIRONMENT=development
LOG_LEVEL=debug LOG_LEVEL=debug
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0
OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>" OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>"

View file

@ -1,5 +1,5 @@
import zod from 'zod'; import zod from 'zod';
import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common';
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
@ -20,6 +20,8 @@ const emptyString = <T extends zod.ZodType>(input: T) => {
const EnvironmentModel = zod.object({ const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString.optional()), PORT: emptyString(NumberFromString.optional()),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
ENVIRONMENT: emptyString(zod.string().optional()), ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()),
}); });
@ -107,6 +109,10 @@ export const env = {
}, },
http: { http: {
port: base.PORT ?? 6600, port: base.PORT ?? 6600,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
}, },
sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null, sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null,
log: { log: {

View file

@ -83,11 +83,16 @@ async function main() {
await server.listen({ await server.listen({
port: env.http.port, port: env.http.port,
host: '::', host: env.http.host,
ipv6Only: env.http.ipv6Only,
}); });
if (env.prometheus) { if (env.prometheus) {
await startMetrics(env.prometheus.labels.instance, env.prometheus.port); await startMetrics(env.prometheus.labels.instance, {
port: env.prometheus.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
});
} }
} catch (error) { } catch (error) {
server.log.fatal(error); server.log.fatal(error);

View file

@ -1,6 +1,8 @@
REDIS_HOST="localhost" REDIS_HOST="localhost"
REDIS_PORT="6379" REDIS_PORT="6379"
REDIS_PASSWORD="" REDIS_PASSWORD=""
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0
ENCRYPTION_SECRET="97e4094d2463e71a981913cca4e56788" ENCRYPTION_SECRET="97e4094d2463e71a981913cca4e56788"
SCHEMA_CACHE_TTL_MS=5000 SCHEMA_CACHE_TTL_MS=5000
SCHEMA_CACHE_SUCCESS_TTL_MS=5000 SCHEMA_CACHE_SUCCESS_TTL_MS=5000

View file

@ -1,5 +1,5 @@
import zod from 'zod'; import zod from 'zod';
import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common';
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
@ -21,6 +21,8 @@ const emptyString = <T extends zod.ZodType>(input: T) => {
const EnvironmentModel = zod.object({ const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString().optional()), PORT: emptyString(NumberFromString().optional()),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
BODY_LIMIT: NumberFromString().optional().default(/* 15mb in bytes */ 15e6), BODY_LIMIT: NumberFromString().optional().default(/* 15mb in bytes */ 15e6),
ENVIRONMENT: emptyString(zod.string().optional()), ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()),
@ -144,6 +146,10 @@ export const env = {
encryptionSecret: base.ENCRYPTION_SECRET, encryptionSecret: base.ENCRYPTION_SECRET,
http: { http: {
port: base.PORT ?? 6500, port: base.PORT ?? 6500,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
bodyLimit: base.BODY_LIMIT, bodyLimit: base.BODY_LIMIT,
}, },
tracing: { tracing: {

View file

@ -146,11 +146,16 @@ async function main() {
await server.listen({ await server.listen({
port: env.http.port, port: env.http.port,
host: '::', host: env.http.host,
ipv6Only: env.http.ipv6Only,
}); });
if (env.prometheus) { if (env.prometheus) {
await startMetrics(env.prometheus.labels.instance, env.prometheus.port); await startMetrics(env.prometheus.labels.instance, {
port: env.prometheus.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
});
} }
} catch (error) { } catch (error) {
server.log.fatal(error); server.log.fatal(error);

View file

@ -1,5 +1,7 @@
ENVIRONMENT=development ENVIRONMENT=development
LOG_LEVEL=debug LOG_LEVEL=debug
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_HOST=localhost POSTGRES_HOST=localhost

View file

@ -13,9 +13,8 @@
}, },
"devDependencies": { "devDependencies": {
"@envelop/core": "5.5.1", "@envelop/core": "5.5.1",
"@envelop/graphql-jit": "8.0.3", "@envelop/graphql-jit": "11.1.1",
"@envelop/graphql-modules": "9.1.0", "@envelop/graphql-modules": "9.1.1",
"@envelop/opentelemetry": "6.3.1",
"@envelop/types": "5.0.0", "@envelop/types": "5.0.0",
"@escape.tech/graphql-armor-max-aliases": "2.6.2", "@escape.tech/graphql-armor-max-aliases": "2.6.2",
"@escape.tech/graphql-armor-max-depth": "2.4.2", "@escape.tech/graphql-armor-max-depth": "2.4.2",
@ -24,7 +23,7 @@
"@fastify/cookie": "11.0.2", "@fastify/cookie": "11.0.2",
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@fastify/formbody": "8.0.2", "@fastify/formbody": "8.0.2",
"@graphql-hive/plugin-opentelemetry": "1.3.0", "@graphql-hive/plugin-opentelemetry": "1.4.26",
"@graphql-hive/yoga": "workspace:*", "@graphql-hive/yoga": "workspace:*",
"@graphql-tools/merge": "9.1.1", "@graphql-tools/merge": "9.1.1",
"@graphql-yoga/plugin-response-cache": "3.15.4", "@graphql-yoga/plugin-response-cache": "3.15.4",

View file

@ -1,5 +1,5 @@
import zod from 'zod'; import zod from 'zod';
import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common';
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
@ -20,6 +20,8 @@ const emptyString = <T extends zod.ZodType>(input: T) => {
const EnvironmentModel = zod.object({ const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString.optional()), PORT: emptyString(NumberFromString.optional()),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
ENVIRONMENT: emptyString(zod.string().optional()), ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()),
ENCRYPTION_SECRET: emptyString(zod.string()), ENCRYPTION_SECRET: emptyString(zod.string()),
@ -417,6 +419,10 @@ export const env = {
}, },
http: { http: {
port: base.PORT ?? 3001, port: base.PORT ?? 3001,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
}, },
postgres: { postgres: {
host: postgres.POSTGRES_HOST, host: postgres.POSTGRES_HOST,

View file

@ -610,12 +610,17 @@ export async function main() {
} }
if (env.prometheus) { if (env.prometheus) {
await startMetrics(env.prometheus.labels.instance, env.prometheus.port); await startMetrics(env.prometheus.labels.instance, {
port: env.prometheus.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
});
} }
await server.listen({ await server.listen({
port: env.http.port, port: env.http.port,
host: '::', host: env.http.host,
ipv6Only: env.http.ipv6Only,
}); });
} catch (error) { } catch (error) {
server.log.fatal(error); server.log.fatal(error);

View file

@ -4,7 +4,8 @@
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts",
"./listen-options": "./src/listen-options.ts"
}, },
"peerDependencies": { "peerDependencies": {
"@sentry/node": "^7.0.0", "@sentry/node": "^7.0.0",
@ -13,18 +14,18 @@
}, },
"devDependencies": { "devDependencies": {
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@graphql-hive/logger": "1.0.9", "@graphql-hive/logger": "1.1.0",
"@graphql-hive/plugin-opentelemetry": "1.3.0", "@graphql-hive/plugin-opentelemetry": "1.4.26",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.1",
"@opentelemetry/auto-instrumentations-node": "0.67.2", "@opentelemetry/auto-instrumentations-node": "0.75.0",
"@opentelemetry/context-async-hooks": "2.2.0", "@opentelemetry/context-async-hooks": "2.7.1",
"@opentelemetry/exporter-trace-otlp-http": "0.208.0", "@opentelemetry/exporter-trace-otlp-http": "0.217.0",
"@opentelemetry/instrumentation": "0.208.0", "@opentelemetry/instrumentation": "0.217.0",
"@opentelemetry/resources": "2.2.0", "@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-node": "0.208.0", "@opentelemetry/sdk-node": "0.217.0",
"@opentelemetry/sdk-trace-base": "2.2.0", "@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/sdk-trace-node": "2.2.0", "@opentelemetry/sdk-trace-node": "2.7.1",
"@opentelemetry/semantic-conventions": "1.38.0", "@opentelemetry/semantic-conventions": "1.40.0",
"@sentry/integrations": "7.114.0", "@sentry/integrations": "7.114.0",
"@sentry/node": "7.120.2", "@sentry/node": "7.120.2",
"@sentry/types": "7.120.2", "@sentry/types": "7.120.2",

View file

@ -5,6 +5,7 @@ export * from './metrics';
export * from './heartbeats'; export * from './heartbeats';
export * from './trpc'; export * from './trpc';
export * from './tracing'; export * from './tracing';
export { resolveServerListenOptions } from './listen-options';
export { registerShutdown } from './graceful-shutdown'; export { registerShutdown } from './graceful-shutdown';
export { cleanRequestId, maskToken } from './helpers'; export { cleanRequestId, maskToken } from './helpers';
export { sentryInit } from './sentry'; export { sentryInit } from './sentry';

View file

@ -0,0 +1,46 @@
import { resolveServerListenOptions } from './listen-options';
describe('resolveServerListenOptions', () => {
// eslint-disable-next-line no-restricted-syntax -- explicit IPv4 literal required for validation coverage
const ipv4Host = '0.0.0.0';
test('defaults to dual-stack host', () => {
expect(resolveServerListenOptions({})).toEqual({
host: '::',
ipv6Only: false,
});
});
test('supports ipv4-only host', () => {
expect(
resolveServerListenOptions({
serverHost: ipv4Host,
serverHostIpv6Only: '0',
}),
).toEqual({
host: ipv4Host,
ipv6Only: false,
});
});
test('supports ipv6-only wildcard host', () => {
expect(
resolveServerListenOptions({
serverHost: '::',
serverHostIpv6Only: '1',
}),
).toEqual({
host: '::',
ipv6Only: true,
});
});
test('rejects ipv6-only combined with an IPv4 literal host', () => {
expect(() =>
resolveServerListenOptions({
serverHost: ipv4Host,
serverHostIpv6Only: '1',
}),
).toThrow(/SERVER_HOST_IPV6_ONLY=1 is incompatible with IPv4 host "0\.0\.0\.0"/);
});
});

View file

@ -0,0 +1,17 @@
const IPV4_LITERAL = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
export function resolveServerListenOptions(input: {
serverHost?: string;
serverHostIpv6Only?: '0' | '1';
}) {
const host = input.serverHost ?? '::';
const ipv6Only = input.serverHostIpv6Only === '1';
if (ipv6Only && IPV4_LITERAL.test(host)) {
throw new Error(
`Invalid listen options: SERVER_HOST_IPV6_ONLY=1 is incompatible with IPv4 host "${host}".`,
);
}
return { host, ipv6Only } as const;
}

View file

@ -13,10 +13,18 @@ export function reportReadiness(isReady: boolean) {
readiness.set(isReady ? 1 : 0); readiness.set(isReady ? 1 : 0);
} }
type MetricsListenOptions = {
port?: number;
host?: string;
ipv6Only?: boolean;
};
export async function startMetrics( export async function startMetrics(
instanceLabel: string | undefined, instanceLabel: string | undefined,
port = 10_254, options: MetricsListenOptions = {},
): Promise<() => Promise<void>> { ): Promise<() => Promise<void>> {
const { port = 10_254, host = '::', ipv6Only = false } = options;
promClient.collectDefaultMetrics({ promClient.collectDefaultMetrics({
labels: { instance: instanceLabel }, labels: { instance: instanceLabel },
}); });
@ -45,7 +53,8 @@ export async function startMetrics(
await server.listen({ await server.listen({
port, port,
host: '::', host,
ipv6Only,
}); });
return () => server.close(); return () => server.close();

View file

@ -15,7 +15,7 @@
"db:generate": "schemats generate --config schemats.cjs -o src/db/types.ts && prettier --write src/db/types.ts" "db:generate": "schemats generate --config schemats.cjs -o src/db/types.ts && prettier --write src/db/types.ts"
}, },
"devDependencies": { "devDependencies": {
"@graphql-inspector/core": "7.1.2", "@graphql-inspector/core": "7.1.3",
"@hive/postgres": "workspace:*", "@hive/postgres": "workspace:*",
"@hive/service-common": "workspace:*", "@hive/service-common": "workspace:*",
"@sentry/node": "7.120.2", "@sentry/node": "7.120.2",

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,8 @@ REDIS_HOST="localhost"
REDIS_PORT="6379" REDIS_PORT="6379"
REDIS_PASSWORD="" REDIS_PASSWORD=""
PORT=6001 PORT=6001
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0
OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>" OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>"
LOG_LEVEL="debug" LOG_LEVEL="debug"
OPENTELEMETRY_TRACE_USAGE_REQUESTS=1 OPENTELEMETRY_TRACE_USAGE_REQUESTS=1

View file

@ -1,5 +1,5 @@
import zod from 'zod'; import zod from 'zod';
import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common';
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
@ -20,6 +20,8 @@ const emptyString = <T extends zod.ZodType>(input: T) => {
const EnvironmentModel = zod.object({ const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString.optional()), PORT: emptyString(NumberFromString.optional()),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
ENVIRONMENT: emptyString(zod.string().optional()), ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()),
HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()),
@ -131,6 +133,10 @@ export const env = {
release: base.RELEASE ?? 'local', release: base.RELEASE ?? 'local',
http: { http: {
port: base.PORT ?? 6001, port: base.PORT ?? 6001,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
}, },
tracing: { tracing: {
enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT,

View file

@ -169,12 +169,17 @@ export async function main() {
}); });
if (env.prometheus) { if (env.prometheus) {
await startMetrics(env.prometheus.labels.instance, env.prometheus.port); await startMetrics(env.prometheus.labels.instance, {
port: env.prometheus.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
});
} }
await server.listen({ await server.listen({
port: env.http.port, port: env.http.port,
host: '::', host: env.http.host,
ipv6Only: env.http.ipv6Only,
}); });
} catch (error) { } catch (error) {
server.log.fatal(error); server.log.fatal(error);

View file

@ -11,3 +11,5 @@ CLICKHOUSE_PASSWORD="test"
CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS="500" CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS="500"
CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE="1000" CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE="1000"
PORT=4002 PORT=4002
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0

View file

@ -1,5 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import zod from 'zod'; import zod from 'zod';
import { resolveServerListenOptions } from '@hive/service-common';
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
@ -20,6 +21,8 @@ const emptyString = <T extends zod.ZodType>(input: T) => {
const EnvironmentModel = zod.object({ const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString.optional()), PORT: emptyString(NumberFromString.optional()),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
ENVIRONMENT: emptyString(zod.string().optional()), ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()),
HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()),
@ -162,6 +165,10 @@ export const env = {
release: base.RELEASE ?? 'local', release: base.RELEASE ?? 'local',
http: { http: {
port: base.PORT ?? 5000, port: base.PORT ?? 5000,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
}, },
kafka: { kafka: {
concurrency: kafka.KAFKA_CONCURRENCY, concurrency: kafka.KAFKA_CONCURRENCY,

View file

@ -80,11 +80,16 @@ async function main() {
}); });
if (env.prometheus) { if (env.prometheus) {
await startMetrics(env.prometheus.labels.instance, env.prometheus.port); await startMetrics(env.prometheus.labels.instance, {
port: env.prometheus.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
});
} }
await server.listen({ await server.listen({
port: env.http.port, port: env.http.port,
host: '::', host: env.http.host,
ipv6Only: env.http.ipv6Only,
}); });
await start(); await start();
} catch (error) { } catch (error) {

View file

@ -17,5 +17,7 @@ REDIS_PORT="6379"
REDIS_PASSWORD="" REDIS_PASSWORD=""
PORT=4001 PORT=4001
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0
COMMERCE_ENDPOINT="http://localhost:4013" COMMERCE_ENDPOINT="http://localhost:4013"
OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>" OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>"

View file

@ -1,6 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import zod from 'zod'; import zod from 'zod';
import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common';
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
@ -21,6 +21,8 @@ const emptyString = <T extends zod.ZodType>(input: T) => {
const EnvironmentModel = zod.object({ const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString.optional()), PORT: emptyString(NumberFromString.optional()),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
TOKENS_ENDPOINT: zod.string().url(), TOKENS_ENDPOINT: zod.string().url(),
COMMERCE_ENDPOINT: emptyString(zod.string().url().optional()), COMMERCE_ENDPOINT: emptyString(zod.string().url().optional()),
RATE_LIMIT_TTL: emptyString(NumberFromString.optional()).default(30_000), RATE_LIMIT_TTL: emptyString(NumberFromString.optional()).default(30_000),
@ -152,6 +154,10 @@ export const env = {
release: base.RELEASE ?? 'local', release: base.RELEASE ?? 'local',
http: { http: {
port: base.PORT ?? 5000, port: base.PORT ?? 5000,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
}, },
tracing: { tracing: {
enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT,

View file

@ -193,12 +193,17 @@ async function main() {
}); });
if (env.prometheus) { if (env.prometheus) {
await startMetrics(env.prometheus.labels.instance, env.prometheus.port); await startMetrics(env.prometheus.labels.instance, {
port: env.prometheus.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
});
} }
await server.listen({ await server.listen({
port: env.http.port, port: env.http.port,
host: '::', host: env.http.host,
ipv6Only: env.http.ipv6Only,
}); });
await usage.start(); await usage.start();
} catch (error) { } catch (error) {

View file

@ -9,3 +9,5 @@ SCHEMA_ENDPOINT=http://localhost:6500
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD= REDIS_PASSWORD=
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0

View file

@ -9,8 +9,8 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@graphql-hive/logger": "1.0.9", "@graphql-hive/logger": "1.1.0",
"@graphql-inspector/core": "7.1.2", "@graphql-inspector/core": "7.1.3",
"@graphql-inspector/patch": "0.1.3", "@graphql-inspector/patch": "0.1.3",
"@graphql-yoga/redis-event-target": "3.0.3", "@graphql-yoga/redis-event-target": "3.0.3",
"@hive/postgres": "workspace:*", "@hive/postgres": "workspace:*",

View file

@ -1,6 +1,6 @@
import zod from 'zod'; import zod from 'zod';
import { PostgresConnectionParamaters } from '@hive/postgres'; import { PostgresConnectionParamaters } from '@hive/postgres';
import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common';
import { RequestBroker } from './lib/webhooks/send-webhook.js'; import { RequestBroker } from './lib/webhooks/send-webhook.js';
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
@ -22,6 +22,8 @@ const emptyString = <T extends zod.ZodType>(input: T) => {
const EnvironmentModel = zod.object({ const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString.optional()).default(3014), PORT: emptyString(NumberFromString.optional()).default(3014),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
ENVIRONMENT: emptyString(zod.string().optional()), ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()),
HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()),
@ -205,6 +207,10 @@ export const env = {
release: base.RELEASE ?? 'local', release: base.RELEASE ?? 'local',
http: { http: {
port: base.PORT ?? 6260, port: base.PORT ?? 6260,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
}, },
tracing: { tracing: {
enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT,

View file

@ -134,11 +134,16 @@ if (context.email.id === 'mock') {
await server.listen({ await server.listen({
port: env.http.port, port: env.http.port,
host: '::', host: env.http.host,
ipv6Only: env.http.ipv6Only,
}); });
const shutdownMetrics = env.prometheus const shutdownMetrics = env.prometheus
? await startMetrics(env.prometheus.labels.instance, env.prometheus.port) ? await startMetrics(env.prometheus.labels.instance, {
port: env.prometheus.port,
host: env.http.host,
ipv6Only: env.http.ipv6Only,
})
: null; : null;
const runner = await run({ const runner = await run({

View file

@ -1,5 +1,7 @@
APP_BASE_URL="http://localhost:3000" APP_BASE_URL="http://localhost:3000"
ENVIRONMENT="development" ENVIRONMENT="development"
SERVER_HOST=::
SERVER_HOST_IPV6_ONLY=0
# Public GraphQL endpoint # Public GraphQL endpoint
GRAPHQL_PUBLIC_ENDPOINT="http://localhost:3001/graphql" GRAPHQL_PUBLIC_ENDPOINT="http://localhost:3001/graphql"

View file

@ -25,11 +25,12 @@
"@graphiql/toolkit": "0.9.1", "@graphiql/toolkit": "0.9.1",
"@graphql-codegen/client-preset-swc-plugin": "0.2.0", "@graphql-codegen/client-preset-swc-plugin": "0.2.0",
"@graphql-hive/laboratory": "workspace:*", "@graphql-hive/laboratory": "workspace:*",
"@graphql-inspector/core": "7.1.2", "@graphql-inspector/core": "7.1.3",
"@graphql-inspector/patch": "0.1.3", "@graphql-inspector/patch": "0.1.3",
"@graphql-tools/mock": "9.0.25", "@graphql-tools/mock": "9.0.25",
"@graphql-typed-document-node/core": "3.2.0", "@graphql-typed-document-node/core": "3.2.0",
"@headlessui/react": "2.2.0", "@headlessui/react": "2.2.0",
"@hive/service-common": "workspace:*",
"@hookform/resolvers": "3.10.0", "@hookform/resolvers": "3.10.0",
"@ladle/react": "5.1.1", "@ladle/react": "5.1.1",
"@monaco-editor/react": "4.8.0-rc.2", "@monaco-editor/react": "4.8.0-rc.2",

View file

@ -138,7 +138,6 @@ export const ChangesBlock_SchemaChangeFragment = graphql(`
export function ChangesBlock( export function ChangesBlock(
props: { props: {
title: string | React.ReactElement; title: string | React.ReactElement;
severityLevel: SeverityLevelType;
organizationSlug: string; organizationSlug: string;
projectSlug: string; projectSlug: string;
targetSlug: string; targetSlug: string;

View file

@ -1,4 +1,5 @@
import zod from 'zod'; import zod from 'zod';
import { resolveServerListenOptions } from '@hive/service-common/listen-options';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { ALLOWED_ENVIRONMENT_VARIABLES } from './frontend-public-variables'; import { ALLOWED_ENVIRONMENT_VARIABLES } from './frontend-public-variables';
@ -41,6 +42,8 @@ const BaseSchema = zod.object({
NODE_ENV: zod.string().default('development'), NODE_ENV: zod.string().default('development'),
ENVIRONMENT: zod.string(), ENVIRONMENT: zod.string(),
PORT: emptyString(NumberFromString().optional()), PORT: emptyString(NumberFromString().optional()),
SERVER_HOST: emptyString(zod.string().optional()),
SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
APP_BASE_URL: zod.string().url(), APP_BASE_URL: zod.string().url(),
GRAPHQL_PUBLIC_ENDPOINT: zod.string().url(), GRAPHQL_PUBLIC_ENDPOINT: zod.string().url(),
GRAPHQL_PUBLIC_SUBSCRIPTION_ENDPOINT: zod.string().url(), GRAPHQL_PUBLIC_SUBSCRIPTION_ENDPOINT: zod.string().url(),
@ -172,6 +175,10 @@ function buildConfig() {
const config = { const config = {
port: base.PORT ?? 3000, port: base.PORT ?? 3000,
...resolveServerListenOptions({
serverHost: base.SERVER_HOST,
serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY,
}),
release: base.RELEASE ?? 'local', release: base.RELEASE ?? 'local',
nodeEnv: base.NODE_ENV, nodeEnv: base.NODE_ENV,
environment: base.ENVIRONMENT, environment: base.ENVIRONMENT,

View file

@ -0,0 +1,46 @@
import { resolveServerListenOptions } from '@hive/service-common/listen-options';
describe('resolveServerListenOptions', () => {
// eslint-disable-next-line no-restricted-syntax -- explicit IPv4 literal required for validation coverage
const ipv4Host = '0.0.0.0';
test('defaults to dual-stack host and ipv4 fallback enabled', () => {
expect(resolveServerListenOptions({})).toEqual({
host: '::',
ipv6Only: false,
});
});
test('supports ipv6-only wildcard mode', () => {
expect(
resolveServerListenOptions({
serverHost: '::',
serverHostIpv6Only: '1',
}),
).toEqual({
host: '::',
ipv6Only: true,
});
});
test('supports ipv4-only host values', () => {
expect(
resolveServerListenOptions({
serverHost: ipv4Host,
serverHostIpv6Only: '0',
}),
).toEqual({
host: ipv4Host,
ipv6Only: false,
});
});
test('rejects ipv6-only combined with an IPv4 literal host', () => {
expect(() =>
resolveServerListenOptions({
serverHost: ipv4Host,
serverHostIpv6Only: '1',
}),
).toThrow(/SERVER_HOST_IPV6_ONLY=1 is incompatible with IPv4 host "0\.0\.0\.0"/);
});
});

View file

@ -35,7 +35,7 @@ import { TimeAgo } from '@/components/ui/time-ago';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DiffEditor } from '@/components/v2/diff-editor'; import { DiffEditor } from '@/components/v2/diff-editor';
import { FragmentType, graphql, useFragment } from '@/gql'; import { FragmentType, graphql, useFragment } from '@/gql';
import { ProjectType, SeverityLevelType } from '@/gql/graphql'; import { ProjectType } from '@/gql/graphql';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
CheckIcon, CheckIcon,
@ -304,7 +304,7 @@ function ConditionalBreakingChangesMetadataSection(props: {
Get more out of schema checks by enabling conditional breaking changes based on usage data. Get more out of schema checks by enabling conditional breaking changes based on usage data.
<br /> <br />
<DocsLink <DocsLink
href="/management/targets#conditional-breaking-changes" href="/schema-registry/management/targets#conditional-breaking-changes"
className="text-neutral-10 hover:text-neutral-11" className="text-neutral-10 hover:text-neutral-11"
> >
Learn more about conditional breaking changes. Learn more about conditional breaking changes.
@ -387,7 +387,7 @@ function ConditionalBreakingChangesMetadataSection(props: {
). ).
<br /> <br />
<DocsLink <DocsLink
href="/management/targets#conditional-breaking-changes" href="/schema-registry/management/targets#conditional-breaking-changes"
className="text-neutral-10 hover:text-neutral-11" className="text-neutral-10 hover:text-neutral-11"
> >
Learn more about conditional breaking changes. Learn more about conditional breaking changes.
@ -564,7 +564,6 @@ function DefaultSchemaView(props: {
targetSlug={props.targetSlug} targetSlug={props.targetSlug}
schemaCheckId={schemaCheck.id} schemaCheckId={schemaCheck.id}
title={<BreakingChangesTitle />} title={<BreakingChangesTitle />}
severityLevel={SeverityLevelType.Breaking}
changesWithUsage={schemaCheck.breakingSchemaChanges.edges.map(edge => edge.node)} changesWithUsage={schemaCheck.breakingSchemaChanges.edges.map(edge => edge.node)}
conditionBreakingChangeMetadata={schemaCheck.conditionalBreakingChangeMetadata} conditionBreakingChangeMetadata={schemaCheck.conditionalBreakingChangeMetadata}
/> />
@ -578,7 +577,6 @@ function DefaultSchemaView(props: {
targetSlug={props.targetSlug} targetSlug={props.targetSlug}
schemaCheckId={schemaCheck.id} schemaCheckId={schemaCheck.id}
title="Safe Changes" title="Safe Changes"
severityLevel={SeverityLevelType.Safe}
changes={schemaCheck.safeSchemaChanges.edges.map(edge => edge.node)} changes={schemaCheck.safeSchemaChanges.edges.map(edge => edge.node)}
/> />
</div> </div>
@ -768,7 +766,6 @@ function ContractCheckView(props: {
targetSlug={props.targetSlug} targetSlug={props.targetSlug}
schemaCheckId={schemaCheck.id} schemaCheckId={schemaCheck.id}
title={<BreakingChangesTitle />} title={<BreakingChangesTitle />}
severityLevel={SeverityLevelType.Breaking}
changesWithUsage={contractCheck.breakingSchemaChanges.edges.map( changesWithUsage={contractCheck.breakingSchemaChanges.edges.map(
edge => edge.node, edge => edge.node,
)} )}
@ -784,7 +781,6 @@ function ContractCheckView(props: {
targetSlug={props.targetSlug} targetSlug={props.targetSlug}
schemaCheckId={schemaCheck.id} schemaCheckId={schemaCheck.id}
title="Safe Changes" title="Safe Changes"
severityLevel={SeverityLevelType.Safe}
changes={contractCheck.safeSchemaChanges.edges.map(edge => edge.node)} changes={contractCheck.safeSchemaChanges.edges.map(edge => edge.node)}
/> />
</div> </div>

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