Compare commits

..

13 Commits

Author SHA1 Message Date
David Barroso
58eda5bc34 feat(nixops): added postgres18 and remove pg14 and pg15 2025-10-09 12:21:42 +02:00
github-actions[bot]
6ad1cd1900 release(cli): 1.34.0 (#3553)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-09 11:48:54 +02:00
David Barroso
5c5223d871 chore(cli): bump nhost/dashboard to 2.38.4 (#3539)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-09 11:39:23 +02:00
github-actions[bot]
00ef639455 release(dashboard): 2.38.4 (#3574)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-09 10:38:57 +02:00
David Barroso
a54da9c072 fix(dashboard): remove NODE_ENV from restricted env vars (#3573) 2025-10-09 10:36:25 +02:00
David Barroso
63edfa2600 feat(cli): MCP refactor and documentation prior to official release (#3571) 2025-10-09 08:55:10 +02:00
David Barroso
381baf2e51 feat(docs): added react urql guide (#3570) 2025-10-08 11:02:50 +02:00
dependabot[bot]
951ce168e8 chore(ci): bump github/codeql-action from 3 to 4 (#3569)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 08:34:39 +02:00
github-actions[bot]
be8f4e5b1b release(dashboard): 2.38.3 (#3563)
Co-authored-by: dbm03 <dbm03@users.noreply.github.com>
2025-10-07 15:03:40 +02:00
David BM
010573cc31 fix(dashboard): improve remote schema preview search (#3558) 2025-10-07 14:37:21 +02:00
David BM
629bbe7a78 fix(dashboard): remote schema edit graphql customizations, default value for root field namespace is empty (#3565) 2025-10-06 14:56:46 +02:00
David BM
166889be1b fix(dashboard): show paused banner in Run page (#3564) 2025-10-06 10:04:30 +02:00
David BM
c80f6292c6 fix(dashboard): show paused banner in remote schemas/database page if project is paused (#3557) 2025-10-06 08:59:49 +02:00
98 changed files with 24550 additions and 804 deletions

View File

@@ -88,6 +88,7 @@ jobs:
- name: Bump version in source code
run: |
find cli -type f -exec sed -i 's/"nhost\/dashboard:[^"]*"/"nhost\/dashboard:${{ inputs.VERSION }}"/g' {} +
sed -i 's/"nhost\/dashboard:[^"]*"/"nhost\/dashboard:${{ inputs.VERSION }}"/g' docs/reference/cli/commands.mdx
- name: "Create Pull Request"
uses: peter-evans/create-pull-request@v7

View File

@@ -26,7 +26,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -37,7 +37,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -51,4 +51,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4

View File

@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file.
## [cli@1.34.0] - 2025-10-09
### 🚀 Features
- *(cli)* Added mcp server functionality from mcp-nhost (#3550)
- *(cli)* Mcp: move configuration to .nhost folder and integrate cloud credentials (#3555)
- *(cli)* Mcp: added support for environment variables in the configuration (#3556)
- *(cli)* MCP refactor and documentation prior to official release (#3571)
### 🐛 Bug Fixes
- *(dashboard)* Remove NODE_ENV from restricted env vars (#3573)
### ⚙️ Miscellaneous Tasks
- *(nixops)* Update nhost-cli (#3554)
- *(cli)* Bump nhost/dashboard to 2.38.4 (#3539)
## [cli@1.33.0] - 2025-10-02
### 🚀 Features

View File

@@ -1,202 +0,0 @@
# nhost mcp
A Model Context Protocol (MCP) server implementation for interacting with Nhost Cloud projects and services.
## Overview
MCP-Nhost is designed to provide a unified interface for managing Nhost projects through the Model Context Protocol. It enables seamless interaction with Nhost Cloud services, offering a robust set of tools for project management and configuration.
## Available Tools
The following tools are currently exposed through the MCP interface:
1. **cloud-get-graphql-schema**
- Provides the GraphQL schema for the Nhost Cloud platform
- Gives access to queries and mutations available for cloud management
2. **cloud-graphql-query**
- Executes GraphQL queries and mutations against the Nhost Cloud platform
- Enables project and organization management
- Allows querying and updating project configurations
- Mutations require enabling them when starting the server
3. **local-get-graphql-schema**
- Retrieves the GraphQL schema for local Nhost development projects
- Provides access to project-specific queries and mutations
- Helps understand available operations for local development helping generating code
- Uses "user" role unless specified otherwise
4. **local-graphql-query**
- Executes GraphQL queries against local Nhost development projects
- Enables testing and development of project-specific operations
- Supports both queries and mutations for local development
- Uses "user" role unless specified otherwise
5. **local-config-server-get-schema**
- Retrieves the GraphQL schema for the local config server
- Helps understand available configuration options for local projects
6. **local-config-server-query**
- Executes GraphQL queries against the local config server
- Enables querying and modifying local project configuration
- Changes require running 'nhost up' to take effect
7. **local-get-management-graphql-schema**
- Retrieves the GraphQL management schema for local projects
- Useful for understanding how to manage Hasura metadata, migrations, and permissions
- Provides insight into available management operations before using the management tool
8. **local-manage-graphql**
- Interacts with GraphQL's management endpoints for local projects
- Manages Hasura metadata, migrations, permissions, and remote schemas
- Creates and applies database migrations
- Handles data and schema changes through proper migration workflows
- Manages roles and permissions
9. **project-get-graphql-schema**
- Retrieves the GraphQL schema for Nhost Cloud projects
- Provides access to project-specific queries and mutations
- Uses "user" role unless specified otherwise
10. **project-graphql-query**
- Executes GraphQL queries against Nhost Cloud projects
- Enables interaction with live project data
- Supports both queries and mutations (need to be allowed)
- Uses "user" role unless specified otherwise
11. **search**
- Searches Nhost's official documentation
- Provides information about Nhost features, APIs, and guides
- Helps find relevant documentation for implementing features or solving issues
- Returns links to detailed documentation pages
## Screenshots and Examples
You can find screenshots and examples of the current features and tools in the [screenshots](docs/mcp/screenshots.md) file.
## Installing
To install mcp-nhost, you can use the following command:
```bash
sudo curl -L https://raw.githubusercontent.com/nhost/mcp-nhost/main/get.sh | bash
```
## Configuring
After installing mcp-nhost, you will need to configure it. You can do this by running the command `mcp-nhost config` in your terminal. See [CONFIG.md](docs/mcp/CONFIG.md) for more details.
## Configuring clients
#### Cursor
1. Go to "Cursor Settings"
2. Click on "MCP"
3. Click on "+ Add new global MCP server"
4. Add the following object inside `"mcpServers"`:
```json
"mcp-nhost": {
"command": "/usr/local/bin/mcp-nhost",
"args": [
"start",
],
}
```
## CLI Usage
For help on how to use the CLI, you can run:
```bash
mcp-nhost --help
```
Or check [USAGE.md](docs/mcp/USAGE.md) for more details.
## Troubleshooting
If you run into issues using the MCP server you can try running the tools yourself. For example:
```
# cloud-get-graphql-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"cloud-get-graphql-schema","arguments":{}},"id":1}' | mcp-nhost start
# cloud-graphql-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"cloud-graphql-query","arguments":{"query":"{ apps { id subdomain name } }"}},"id":1}' | mcp-nhost start
# local-get-graphql-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-get-graphql-schema","arguments":{"role":"user"}},"id":1}' | mcp-nhost start
# local-graphql-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-graphql-query","arguments":{"query":"{ users { id } }", "role":"admin"}},"id":1}' | mcp-nhost start
# local-config-server-get-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-config-server-get-schema","arguments":{}},"id":1}' | mcp-nhost start
# local-config-server-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-config-server-query","arguments":{"query":"{ config(appID: \"00000000-0000-0000-0000-000000000000\", resolve: true) { postgres { version } } }"}},"id":1}' | mcp-nhost start
# local-get-management-graphql-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-get-management-graphql-schema","arguments":{}},"id":1}' | mcp-nhost start
# local-manage-graphql
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"local-manage-graphql","arguments":{"body":"{\"type\":\"export_metadata\",\"args\":{}}","endpoint":"https://local.hasura.local.nhost.run/v1/metadata"}},"id":1}' | mcp-nhost start
# project-get-graphql-schema - set projectSubdomain to your own project
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"project-get-graphql-schema","arguments":{"projectSubdomain":"replaceMe", "role": "user"}},"id":1}' | mcp-nhost start
# project-graphql-query - set projectSubdomain to your own project
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"project-graphql-query","arguments":{"projectSubdomain":"replaceMe","query":"{ users { id } }", "role":"admin"}},"id":1}' | mcp-nhost start
# search
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search","arguments":{"query":"how to enable magic links"}},"id":1}' | mcp-nhost start
# resources
echo '{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"schema://asdsadasd"},"id":1}' | go run main.go mcp start
```
## Roadmap
- ✅ Cloud platform: Basic project and organization management
- ✅ Cloud projects: Configuration management
- ✅ Local projects: Configuration management
- ✅ Local projects: Graphql Schema awareness and query execution
- ✅ Cloud projects: Schema awareness and query execution
- ✅ Local projects: Create migrations
- ✅ Local projects: Manage permissions and relationships
- ✅ Documentation: integrate or document use of mintlify's mcp server
- ✅ Local projects: Auth and Storage schema awareness (maybe via mintlify?)
- ✅ Cloud projects: Auth and Storage schema awareness (maybe via mintlify?)
- 🔄 Local projects: Manage more metadata
If you have any suggestions or feature requests, please feel free to open an issue for discussion.
## Security and Privacy
### Enhanced Protection Layer
The MCP server is designed with security at its core, providing an additional protection layer beyond your existing GraphQL permissions. Key security features include:
- **Authentication enforcement** for all requests
- **Permission and role respect** based on your existing authorization system and the credentials provided
- **Query/mutation filtering** to further restrict allowed operations
### Granular Access Control
One of the MCP server's key security advantages is the ability to specify exactly which operations can pass through, even for authenticated users:
```toml
[[projects]]
subdomain = "my-blog"
region = "eu-central-1"
pat = "nhp_project_specific_pat"
allow_queries = ["getBlogs", "getCommends"]
allow_mutations = ["insertBlog", "insertComment"]
```
With the configuration above, an LLM will be able to only execute the queries and mutations above on behalf of a user even if the user has broader permissions in the Nhost project.
## Contributing
We welcome contributions to mcp-nhost! If you have suggestions, bug reports, or feature requests, please open an issue or submit a pull request.

View File

@@ -51,11 +51,18 @@ nhost up
nhost up --ui nhost
```
## MCP Server
The Nhost cli ships with an MCP server that lets you interact with your Nhost projects through AI assistants using the Model Context Protocol. It provides secure, controlled access to your GraphQL data, project configuration, and documentation—with granular permissions that let you specify exactly which queries and mutations an LLM can execute. For development, it streamlines your workflow by enabling AI-assisted schema management, metadata changes, and migrations, while providing direct access to your GraphQL schema for intelligent query building.
You can read more about the MCP server in the [MCP Server documentation](https://docs.nhost.io/platform/cli/mcp/overview).
## Documentation
- [Get started with Nhost CLI (longer version)](https://docs.nhost.io/platform/overview/get-started-with-nhost-cli)
- [Nhost CLI](https://docs.nhost.io/platform/cli)
- [Reference](https://docs.nhost.io/reference/cli)
- [MCP Server](https://docs.nhost.io/platform/cli/mcp/overview)
## Build from Source

View File

@@ -56,7 +56,7 @@ func CommandCloud() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.38.0",
Value: "nhost/dashboard:2.38.4",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct

View File

@@ -111,7 +111,7 @@ func CommandUp() *cli.Command { //nolint:funlen
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.38.0",
Value: "nhost/dashboard:2.38.4",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct

View File

@@ -14,6 +14,7 @@ import (
nhostmcp "github.com/nhost/nhost/cli/cmd/mcp"
"github.com/nhost/nhost/cli/cmd/mcp/start"
"github.com/nhost/nhost/cli/cmd/user"
"github.com/nhost/nhost/cli/mcp/resources"
"github.com/nhost/nhost/cli/mcp/tools/cloud"
"github.com/nhost/nhost/cli/mcp/tools/docs"
"github.com/nhost/nhost/cli/mcp/tools/project"
@@ -96,8 +97,14 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
Experimental: nil,
Logging: nil,
Prompts: nil,
Resources: nil,
Sampling: nil,
Resources: &struct {
Subscribe bool "json:\"subscribe,omitempty\""
ListChanged bool "json:\"listChanged,omitempty\""
}{
Subscribe: false,
ListChanged: false,
},
Sampling: nil,
Tools: &struct {
ListChanged bool "json:\"listChanged,omitempty\""
}{
@@ -108,10 +115,22 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
Name: "mcp",
Version: "",
},
Instructions: start.ServerInstructions + `Configured projects:
Instructions: start.ServerInstructions + `
Configured projects:
- local (local): Local development project running via the Nhost CLI
- asdasdasdasdasd (eu-central-1): Staging project for my awesome app
- qweqweqweqweqwe (us-east-1): Production project for my awesome app
The following resources are available:
- schema://nhost-cloud: Schema to interact with the Nhost Cloud. Projects are equivalent
to apps in the schema. IDs are typically uuids.
- schema://graphql-management: GraphQL's management schema for an Nhost project.
This tool is useful to properly understand how manage hasura metadata, migrations,
permissions, remote schemas, etc.
- schema://nhost.toml: Cuelang schema for the nhost.toml configuration file. Run nhost
config validate after making changes to your nhost.toml file to ensure it is valid.
`,
Result: mcp.Result{
Meta: nil,
@@ -166,24 +185,29 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
Type: "object",
Properties: map[string]any{
"role": map[string]any{
"default": string("user"),
"description": string("Role to use when fetching the schema. Useful only services `local` and `project`"),
"description": string("role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ"),
"type": string("string"),
},
"service": map[string]any{
"enum": []any{
string("nhost"), string("config-schema"), string("graphql-management"),
string("project"),
},
"type": string("string"),
},
"subdomain": map[string]any{
"description": string("Project to get the GraphQL schema for. Required when service is `project`"),
"enum": []any{string("local"), string("asdasdasdasdasd"), string("qweqweqweqweqwe")},
"type": string("string"),
},
"mutations": map[string]any{
"description": string("list of mutations to fetch"),
"type": string("array"),
},
"queries": map[string]any{
"description": string("list of queries to fetch"),
"type": string("array"),
},
"summary": map[string]any{
"default": bool(true),
"description": string("only return a summary of the schema"),
"type": string("boolean"),
},
},
Required: []string{"service"},
Required: []string{"role", "subdomain"},
},
Annotations: mcp.ToolAnnotation{
Title: "Get GraphQL/API schema for various services",
@@ -213,9 +237,8 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
},
},
"role": map[string]any{
"description": "role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ",
"description": "role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ",
"type": "string",
"default": "user",
},
"userId": map[string]any{
"description": string("Overrides X-Hasura-User-Id in the GraphQL query/mutation. Credentials must allow it (i.e. admin secret must be in use)"),
@@ -226,7 +249,7 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
"type": "string",
},
},
Required: []string{"query", "subdomain"},
Required: []string{"query", "subdomain", "role"},
},
Annotations: mcp.ToolAnnotation{
Title: "Perform GraphQL Query on Nhost Project running on Nhost Cloud",
@@ -246,6 +269,10 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
"description": "The body for the HTTP request",
"type": "string",
},
"path": map[string]any{
"description": "The path for the HTTP request",
"type": "string",
},
"subdomain": map[string]any{
"description": "Project to perform the GraphQL management operation against",
"type": "string",
@@ -256,7 +283,7 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
},
},
},
Required: []string{"subdomain", "body"},
Required: []string{"subdomain", "path", "body"},
},
Annotations: mcp.ToolAnnotation{
Title: "Manage GraphQL's Metadata on an Nhost Development Project",
@@ -296,24 +323,60 @@ func TestStart(t *testing.T) { //nolint:cyclop,maintidx,paralleltest
t.Errorf("ListToolsResult mismatch (-want +got):\n%s", diff)
}
if res.Capabilities.Resources != nil {
resources, err := mcpClient.ListResources(
context.Background(),
mcp.ListResourcesRequest{}, //nolint:exhaustruct
)
if err != nil {
t.Fatalf("failed to list resources: %v", err)
}
resourceList, err := mcpClient.ListResources(
context.Background(),
mcp.ListResourcesRequest{}, //nolint:exhaustruct
)
if err != nil {
t.Fatalf("failed to list resources: %v", err)
}
if diff := cmp.Diff(
resources,
//nolint:exhaustruct
&mcp.ListResourcesResult{
Resources: []mcp.Resource{},
if diff := cmp.Diff(
resourceList,
//nolint:exhaustruct
&mcp.ListResourcesResult{
Resources: []mcp.Resource{
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://graphql-management",
Name: "graphql-management",
Description: resources.GraphqlManagementDescription,
MIMEType: "text/plain",
},
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://nhost-cloud",
Name: "nhost-cloud",
Description: resources.CloudDescription,
MIMEType: "text/plain",
},
{
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9,
},
},
URI: "schema://nhost.toml",
Name: "nhost.toml",
Description: resources.NhostTomlResourceDescription,
MIMEType: "text/plain",
},
},
); diff != "" {
t.Errorf("ListResourcesResult mismatch (-want +got):\n%s", diff)
}
},
); diff != "" {
t.Errorf("ListResourcesResult mismatch (-want +got):\n%s", diff)
}
if res.Capabilities.Prompts != nil {

View File

@@ -8,6 +8,7 @@ import (
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/mcp/config"
"github.com/nhost/nhost/cli/mcp/nhost/auth"
"github.com/nhost/nhost/cli/mcp/resources"
"github.com/nhost/nhost/cli/mcp/tools/cloud"
"github.com/nhost/nhost/cli/mcp/tools/docs"
"github.com/nhost/nhost/cli/mcp/tools/project"
@@ -25,21 +26,20 @@ const (
// this seems to be largely ignored by clients, or at least by cursor.
// we also need to look into roots and resources as those might be helpful.
ServerInstructions = `
This is an MCP server to interact with Nhost Cloud and with projects running on it and
also with Nhost local development projects.
This is an MCP server to interact with the Nhost Cloud and with Nhost projects.
Important notes to anyone using this MCP server. Do not use this MCP server without
following these instructions:
1. Make sure you are clear on which environment the user wants to operate against.
2. Before attempting to call any tool *-graphql-query, always make sure you read the various
resources and use the get-schema tool to get the required schemas
3. Apps and projects are the same and while users may talk about projects in the GraphQL
2. Before attempting to call any tool, always make sure you list resources, roots, and
resource templates to understand what is available.
3. Apps and projects are the same and while users may talk about projects in Nhost's GraphQL
api those are referred as apps.
4. IDs are always UUIDs so if you have anything else (like an app/project name) you may need
to first get the ID using the *-graphql-query tool.
5. If you have an error querying the GraphQL API, please check the schema again. The schema may
4. If you have an error querying the GraphQL API, please check the schema again. The schema may
have changed and the query you are using may be invalid.
5. Always follow the instructions provided by each tool. If you need to deviate from these
instructions, please, confirm with the user before doing so.
`
)
@@ -83,7 +83,10 @@ func action(ctx context.Context, cmd *cli.Command) error {
}
ServerInstructions := ServerInstructions
ServerInstructions += "\n\n"
ServerInstructions += cfg.Projects.Instructions()
ServerInstructions += "\n"
ServerInstructions += resources.Instructions()
mcpServer := server.NewMCPServer(
cmd.Root().Name,
@@ -91,6 +94,10 @@ func action(ctx context.Context, cmd *cli.Command) error {
server.WithInstructions(ServerInstructions),
)
if err := resources.Register(cfg, mcpServer); err != nil {
return cli.Exit(fmt.Sprintf("failed to register resources: %s", err), 1)
}
if cfg.Cloud != nil {
if err := registerCloud(
cmd,

View File

@@ -1,80 +0,0 @@
# Configuration
This document describes all available configuration options for the Nhost MCP tool. The configuration file uses TOML format.
## TOML
```toml
# Cloud configuration for managing Nhost Cloud projects and organizations
# Remove section to disable this access
[cloud]
# Personal Access Token (PAT) for Nhost Cloud API authentication
# Get one at: https://app.nhost.io/account
pat = "your-pat-here"
# Enable mutations on Nhost Cloud configurations
# When false, only queries are allowed
enable_mutations = true
# Local configuration for interacting with Nhost CLI projects
# Remove section to disable access
[local]
# Admin secret for local project authentication
admin_secret = "your-admin-secret"
# Optional: Custom config server URL
# Default: https://local.dashboard.local.nhost.run/v1/configserver/graphql
config_server_url = "your-custom-url"
# Optional: Custom GraphQL URL
# Default: https://local.graphql.local.nhost.run/v1
graphql_url = "your-custom-url"
# Project-specific configurations
[[projects]]
# Project subdomain (required)
subdomain = "your-project-subdomain"
# Project region (required)
region = "your-project-region"
# Authentication: Use either admin_secret or pat
# Admin secret for project access
admin_secret = "your-project-admin-secret"
# OR
# Project-specific PAT
pat = "your-project-pat"
# List of allowed GraphQL queries
# Use ["*"] to allow all queries, [] to disable all
allow_queries = ["*"]
# List of allowed GraphQL mutations
# Use ["*"] to allow all mutations, [] to disable all
# Only effective if mutations are enabled for the project
allow_mutations = ["*"]
```
## Example Configuration
```toml
[cloud]
pat = "1234567890abcdef"
enable_mutations = true
[local]
admin_secret = "nhost-admin-secret"
[[projects]]
subdomain = "my-app"
region = "eu-central-1"
admin_secret = "project-admin-secret"
allow_queries = ["*"]
allow_mutations = ["createUser", "updateUser"]
[[projects]]
subdomain = "another-app"
region = "us-east-1"
pat = "nhp_project_specific_pat"
allow_queries = ["getUsers", "getPosts"]
allow_mutations = []
```

View File

@@ -1,96 +0,0 @@
# NAME
nhost-mcp - Nhost's Model Context Protocol (MCP) server
# SYNOPSIS
nhost-mcp
```
[--help|-h]
[--version|-v]
```
**Usage**:
```
nhost-mcp [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...]
```
# GLOBAL OPTIONS
**--help, -h**: show help
**--version, -v**: print the version
# COMMANDS
## docs
Generate markdown documentation for the CLI
**--help, -h**: show help
### help, h
Shows a list of commands or help for one command
## config
Generate and save configuration file
**--config-file**="": Configuration file path (default: /Users/dbarroso/.config/nhost/mcp-nhost.toml)
**--confirm**: Skip confirmation prompt
**--help, -h**: show help
### help, h
Shows a list of commands or help for one command
## start
Starts the MCP server
**--bind**="": Bind address in the form <host>:<port>. If omitted use stdio
**--config-file**="": Path to the config file (default: /Users/dbarroso/.config/nhost/mcp-nhost.toml)
**--help, -h**: show help
### help, h
Shows a list of commands or help for one command
## gen
Generate GraphQL schema for Nhost Cloud
**--help, -h**: show help
**--nhost-pat**="": Personal Access Token
**--with-mutations**: Include mutations in the generated schema
### help, h
Shows a list of commands or help for one command
## upgrade
Checks if there is a new version and upgrades it
**--confirm**: Confirm the upgrade without prompting
**--help, -h**: show help
### help, h
Shows a list of commands or help for one command
## help, h
Shows a list of commands or help for one command

View File

@@ -1,49 +0,0 @@
# Screenshots
Listing cloud projects:
<img src="screenshots/101-cloud-projects.png" width="600" alt="listing cloud projects">
Changing cloud project's configuration:
<img src="screenshots/102-cloud-project-config.png" width="600" alt="changing cloud project's configuration">
Querying cloud project's configuration:
<img src="screenshots/103-cloud-project-config2.png" width="600" alt="querying cloud project's configuration">
Querying local project's schema:
<img src="screenshots/201-local-schema.png" width="600" alt="querying local project's schema">
Generating code from local project's schema:
<img src="screenshots/202-local-code.png" alt="generating code from local project's schema">
Resulting code:
<img src="screenshots/203-result.png" alt="resulting code">
Querying local project's configuration:
<img src="screenshots/204-local-config-query.png" width="600" alt="querying local project's configuration">
Modifying local project's configuration:
<img src="screenshots/205-local-config-change.png" width="600" alt="modifying local project's configuration">
Querying cloud project's schema:
<img src="screenshots/301-project-schema.png" width="600" alt="project schema">
Querying cloud project's data:
<img src="screenshots/302-project-query.png" width="600" alt="project data">
Managing cloud project's data:
<img src="screenshots/303-project-mutation.png" width="600" alt="project mutation">
Analysing cloud project's data:
<img src="screenshots/304-project-data-analysis.png" width="600" alt="project data analysis">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

View File

@@ -3,6 +3,10 @@
name = 'GREET'
value = 'Sayonara'
[[global.environment]]
name = 'NODE_ENV'
value = 'production'
[hasura]
version = 'v2.46.0-ce'
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'

View File

@@ -1,6 +1,7 @@
package graphql
import (
"encoding/json"
"fmt"
"sort"
"strings"
@@ -86,6 +87,30 @@ func ParseSchema(response ResponseIntrospection, filter Filter) string { //nolin
return render(neededQueries, neededMutations, neededTypes)
}
func SummarizeSchema(response ResponseIntrospection) string {
summary := map[string][]string{
"query": make([]string, len(response.Data.Schema.QueryType.Fields)),
}
for i, query := range response.Data.Schema.QueryType.Fields {
summary["query"][i] = query.Name
}
if response.Data.Schema.MutationType != nil {
summary["mutation"] = make([]string, len(response.Data.Schema.MutationType.Fields))
for _, mutation := range response.Data.Schema.MutationType.Fields {
summary["mutation"] = append(summary["mutation"], mutation.Name)
}
}
b, err := json.MarshalIndent(summary, "", " ")
if err != nil {
return fmt.Sprintf("failed to marshal summary: %v", err)
}
return string(b)
}
func filterNestedArgs(
args []InputValue, neededTypes map[string]Type,
) []InputValue {

View File

@@ -24,6 +24,10 @@ func checkAllowedOperation(
selectionSet ast.SelectionSet,
allowed []string,
) error {
if slices.Contains(allowed, "*") {
return nil
}
for _, v := range selectionSet {
if v, ok := v.(*ast.Field); ok {
if len(v.SelectionSet) > 0 && !slices.Contains(allowed, v.Name) {

View File

@@ -0,0 +1,69 @@
package resources
import (
"context"
_ "embed"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/config"
)
//go:embed cloud_schema.graphql
var schemaGraphql string
//go:embed cloud_schema-with-mutations.graphql
var schemaGraphqlWithMutations string
const (
CloudResourceURI = "schema://nhost-cloud"
CloudDescription = `Schema to interact with the Nhost Cloud. Projects are equivalent
to apps in the schema. IDs are typically uuids.`
)
type Cloud struct {
schema string
}
func NewCloud(cfg *config.Config) *Cloud {
schema := schemaGraphql
if cfg.Cloud.EnableMutations {
schema = schemaGraphqlWithMutations
}
return &Cloud{
schema: schema,
}
}
func (t *Cloud) Register(server *server.MCPServer) {
server.AddResource(
mcp.Resource{
URI: CloudResourceURI,
Name: "nhost-cloud",
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9.0, //nolint:mnd
},
},
Description: CloudDescription,
MIMEType: "text/plain",
Meta: nil,
},
t.handle,
)
}
func (t *Cloud) handle(
_ context.Context, request mcp.ReadResourceRequest,
) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: "text/plain",
Text: t.schema,
Meta: nil,
},
}, nil
}

View File

@@ -1,5 +0,0 @@
package resources
import "errors"
var ErrParameterRequired = errors.New("parameter required")

View File

@@ -0,0 +1,54 @@
package resources
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/nhost/graphql"
)
const (
GraphqlManagementResourceURI = "schema://graphql-management"
GraphqlManagementDescription = `GraphQL's management schema for an Nhost project.
This tool is useful to properly understand how manage hasura metadata, migrations,
permissions, remote schemas, etc.`
)
type GraphqlManagement struct{}
func NewGraphqlManagement() *GraphqlManagement {
return &GraphqlManagement{}
}
func (t *GraphqlManagement) Register(server *server.MCPServer) {
server.AddResource(
mcp.Resource{
URI: GraphqlManagementResourceURI,
Name: "graphql-management",
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9.0, //nolint:mnd
},
},
Description: GraphqlManagementDescription,
MIMEType: "text/plain",
Meta: nil,
},
t.handle,
)
}
func (t *GraphqlManagement) handle(
_ context.Context, request mcp.ReadResourceRequest,
) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: "text/plain",
Text: graphql.Schema,
Meta: nil,
},
}, nil
}

View File

@@ -0,0 +1,57 @@
package resources
import (
"context"
_ "embed"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
//go:embed nhost_toml_schema.cue
var schemaNhostToml string
const (
NhostTomlResourceURI = "schema://nhost.toml"
NhostTomlResourceDescription = `Cuelang schema for the nhost.toml configuration file. Run nhost
config validate after making changes to your nhost.toml file to ensure it is valid.`
)
type NhostToml struct{}
func NewNhostToml() *NhostToml {
return &NhostToml{}
}
func (t *NhostToml) Register(server *server.MCPServer) {
server.AddResource(
mcp.Resource{
URI: NhostTomlResourceURI,
Name: "nhost.toml",
Annotated: mcp.Annotated{
Annotations: &mcp.Annotations{
Audience: []mcp.Role{"agent"},
Priority: 9.0, //nolint:mnd
},
},
Description: NhostTomlResourceDescription,
MIMEType: "text/plain",
Meta: nil,
},
t.handle,
)
}
//go:generate cp ../../../vendor/github.com/nhost/be/services/mimir/schema/schema.cue nhost_toml_schema.cue
func (t *NhostToml) handle(
_ context.Context, request mcp.ReadResourceRequest,
) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: "text/plain",
Text: schemaNhostToml,
Meta: nil,
},
}, nil
}

View File

@@ -0,0 +1,40 @@
package resources
import (
"fmt"
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/config"
)
func Instructions() string {
return "The following resources are available:\n\n" +
fmt.Sprintf("- %s: %s\n", CloudResourceURI, CloudDescription) +
fmt.Sprintf("- %s: %s\n", GraphqlManagementResourceURI, GraphqlManagementDescription) +
fmt.Sprintf("- %s: %s\n", NhostTomlResourceURI, NhostTomlResourceDescription)
}
func Register(cfg *config.Config, server *server.MCPServer) error {
nt := NewNhostToml()
nt.Register(server)
if cfg.Cloud != nil {
ct := NewCloud(cfg)
ct.Register(server)
}
enableGraphlManagement := false
for _, project := range cfg.Projects {
if project.ManageMetadata {
enableGraphlManagement = true
break
}
}
if enableGraphlManagement {
gmt := NewGraphqlManagement()
gmt.Register(server)
}
return nil
}

View File

@@ -59,7 +59,7 @@ func (t *Tool) handleGraphqlQuery(
allowedMutations := []string{}
if t.withMutations {
allowedMutations = nil
allowedMutations = []string{"*"}
}
var resp graphql.Response[any]
@@ -69,7 +69,7 @@ func (t *Tool) handleGraphqlQuery(
args.Query,
args.Variables,
&resp,
nil,
[]string{"*"},
allowedMutations,
t.interceptors...,
); err != nil {

View File

@@ -24,7 +24,7 @@ const (
## Metadata changes
* When changing metadata always use the /apis/migrate endpoint
* When changing metadata ALWAYS use the /apis/migrate endpoint
* Always perform a bulk request to avoid
having to perform multiple requests
* The admin user always has full permissions to everything by default, no need to configure
@@ -56,6 +56,7 @@ const (
type ManageGraphqlRequest struct {
Body string `json:"body"`
Subdomain string `json:"subdomain"`
Path string `json:"path"`
}
func (t *Tool) registerManageGraphql(mcpServer *server.MCPServer) {
@@ -77,6 +78,11 @@ func (t *Tool) registerManageGraphql(mcpServer *server.MCPServer) {
mcp.Enum(t.cfg.Projects.Subdomains()...),
mcp.Required(),
),
mcp.WithString(
"path",
mcp.Description("The path for the HTTP request"),
mcp.Required(),
),
mcp.WithString(
"body",
mcp.Description("The body for the HTTP request"),
@@ -165,7 +171,7 @@ func (t *Tool) handleManageGraphql(
}
response, err := genericQuery(
ctx, project.GetHasuraURL(), args.Body, http.MethodPost, headers, interceptors,
ctx, project.GetHasuraURL()+args.Path, args.Body, http.MethodPost, headers, interceptors,
)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to execute query", err), nil

View File

@@ -69,9 +69,9 @@ func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
mcp.WithString(
"role",
mcp.Description(
"role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
"role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
),
mcp.DefaultString("user"),
mcp.Required(),
),
mcp.WithString(
"userId",

View File

@@ -1,25 +0,0 @@
package schemas
import (
_ "embed"
"errors"
)
//go:embed cloud_schema.graphql
var schemaGraphql string
//go:embed cloud_schema-with-mutations.graphql
var schemaGraphqlWithMutations string
func (t *Tool) handleResourceCloud() (string, error) {
if t.cfg.Cloud == nil {
return "", errors.New("nhost cloud is not configured") //nolint:err113
}
schema := schemaGraphql
if t.cfg.Cloud.EnableMutations {
schema = schemaGraphqlWithMutations
}
return schema, nil
}

View File

@@ -1,9 +0,0 @@
package schemas
import (
"github.com/nhost/nhost/cli/mcp/nhost/graphql"
)
func (t *Tool) handleGraphqlManagementSchema() string {
return graphql.Schema
}

View File

@@ -1,13 +0,0 @@
package schemas
import (
_ "embed"
)
//go:embed nhost_toml_schema.cue
var schemaNhostToml string
//go:generate cp ../../../../vendor/github.com/nhost/be/services/mimir/schema/schema.cue nhost_toml_schema.cue
func (t *Tool) handleSchemaNhostToml() string {
return schemaNhostToml
}

View File

@@ -25,8 +25,28 @@ type GetGraphqlSchemaRequest struct {
ProjectSubdomain string `json:"projectSubdomain"`
}
func toQueries(q []string) []graphql.Queries {
if q == nil {
return nil
}
queries := make([]graphql.Queries, len(q))
for i, v := range q {
queries[i] = graphql.Queries{
Name: v,
DisableNesting: false,
}
}
return queries
}
func (t *Tool) handleProjectGraphqlSchema(
ctx context.Context, role string, subdomain string,
ctx context.Context,
role string,
subdomain string,
summary bool,
queries, mutations []string,
) (string, error) {
project, err := t.cfg.Projects.Get(subdomain)
if err != nil {
@@ -50,20 +70,25 @@ func (t *Tool) handleProjectGraphqlSchema(
graphql.IntrospectionQuery,
nil,
&introspection,
nil,
[]string{"*"},
nil,
interceptors...,
); err != nil {
return "", fmt.Errorf("failed to query GraphQL schema: %w", err)
}
schema := graphql.ParseSchema(
introspection,
graphql.Filter{
AllowQueries: nil,
AllowMutations: nil,
},
)
var schema string
if summary {
schema = graphql.SummarizeSchema(introspection)
} else {
schema = graphql.ParseSchema(
introspection,
graphql.Filter{
AllowQueries: toQueries(queries),
AllowMutations: toQueries(mutations),
},
)
}
return schema, nil
}

View File

@@ -14,13 +14,6 @@ const (
Get GraphQL/API schemas to interact with various services. Use the "service" parameter to
specify which schema you want. Supported services are:
- nhost: This is the schema to interact with the Nhost Cloud. Projects are equivalent
to apps in the schema. IDs are typically uuids.
- config-schema: Get Cuelang schema for the nhost.toml configuration file. Run nhost
config validate after making changes to your nhost.toml file to ensure it is valid.
- graphql-management: GraphQL's management schema for an Nhost development project
running locally via the Nhost CLI. This tool is useful to properly understand how
manage hasura metadata, migrations, permissions, remote schemas, etc.
- project: Get GraphQL schema for an Nhost project. The "subdomain"
parameter is required to specify which project to get the schema for. The "role"
parameter can be passed to specify the role to use when fetching the schema (defaults
@@ -53,17 +46,12 @@ func (t *Tool) Register(mcpServer *server.MCPServer) {
OpenWorldHint: ptr(true),
},
),
mcp.WithString(
"service",
mcp.Enum("nhost", "config-schema", "graphql-management", "project"),
mcp.Required(),
),
mcp.WithString(
"role",
mcp.Description(
"Role to use when fetching the schema. Useful only services `local` and `project`",
"role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
),
mcp.DefaultString("user"),
mcp.Required(),
),
mcp.WithString(
"subdomain",
@@ -71,6 +59,20 @@ func (t *Tool) Register(mcpServer *server.MCPServer) {
"Project to get the GraphQL schema for. Required when service is `project`",
),
mcp.Enum(t.cfg.Projects.Subdomains()...),
mcp.Required(),
),
mcp.WithBoolean(
"summary",
mcp.Description("only return a summary of the schema"),
mcp.DefaultBool(true),
),
mcp.WithArray(
"queries",
mcp.Description("list of queries to fetch"),
),
mcp.WithArray(
"mutations",
mcp.Description("list of mutations to fetch"),
),
)
@@ -78,31 +80,19 @@ func (t *Tool) Register(mcpServer *server.MCPServer) {
}
type HandleRequest struct {
Service string `json:"service"`
Role string `json:"role,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Role string `json:"role,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Summary bool `json:"summary,omitempty"`
Queries []string `json:"queries,omitempty"`
Mutations []string `json:"mutations,omitempty"`
}
func (t *Tool) handle(
ctx context.Context, _ mcp.CallToolRequest, args HandleRequest,
) (*mcp.CallToolResult, error) {
var (
schema string
err error
schema, err := t.handleProjectGraphqlSchema(
ctx, args.Role, args.Subdomain, args.Summary, args.Queries, args.Mutations,
)
switch args.Service {
case "nhost":
schema, err = t.handleResourceCloud()
case "local-config-server":
schema = t.handleSchemaNhostToml()
case "graphql-management":
schema = t.handleGraphqlManagementSchema()
case "project":
schema, err = t.handleProjectGraphqlSchema(ctx, args.Role, args.Subdomain)
default:
return mcp.NewToolResultError("unknown service: " + args.Service), nil
}
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

View File

@@ -27,9 +27,9 @@ let
"${submodule}/mcp/nhost/auth/openapi.yaml"
"${submodule}/mcp/nhost/graphql/openapi.yaml"
"${submodule}/mcp/tools/schemas/cloud_schema.graphql"
"${submodule}/mcp/tools/schemas/cloud_schema-with-mutations.graphql"
"${submodule}/mcp/tools/schemas/nhost_toml_schema.cue"
"${submodule}/mcp/resources/cloud_schema.graphql"
"${submodule}/mcp/resources/cloud_schema-with-mutations.graphql"
"${submodule}/mcp/resources/nhost_toml_schema.cue"
(inDirectory "${submodule}/cmd/mcp/testdata")
(inDirectory "${submodule}/mcp/graphql/testdata")
];

View File

@@ -2,6 +2,21 @@
All notable changes to this project will be documented in this file.
## [@nhost/dashboard@2.38.4] - 2025-10-09
### 🐛 Bug Fixes
- *(dashboard)* Remove NODE_ENV from restricted env vars (#3573)
## [@nhost/dashboard@2.38.3] - 2025-10-07
### 🐛 Bug Fixes
- *(dashboard)* Show paused banner in remote schemas/database page if project is paused (#3557)
- *(dashboard)* Show paused banner in Run page (#3564)
- *(dashboard)* Remote schema edit graphql customizations, default value for root field namespace is empty (#3565)
- *(dashboard)* Improve remote schema preview search (#3558)
## [@nhost/dashboard@2.38.2] - 2025-09-30
### 🐛 Bug Fixes

View File

@@ -78,10 +78,14 @@ function ProjectLayoutContent({
const blockedPausedProjectPages = [
'database',
'database/browser/[dataSourceSlug]',
'database/browser/[dataSourceSlug]/[schemaSlug]/[tableSlug]',
'graphql',
'graphql/remote-schemas',
'graphql/remote-schemas/[remoteSchemaSlug]',
'hasura',
'users',
'storage',
'run',
'ai/auto-embeddings',
'ai/assistants',
'metrics',

View File

@@ -50,7 +50,6 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
'TERM',
'NODE_VERSION',
'YARN_VERSION',
'NODE_ENV',
'HOME',
].includes(value),
)

View File

@@ -186,6 +186,8 @@ export default function BaseRemoteSchemaForm({
</Text>
<ForwardClientHeadersToggle />
<AdditionalHeadersEditor />
</Box>
<Box className="border-t-1 px-6 py-6">
{graphQLCustomizationsSlot ?? <GraphQLCustomizations />}
</Box>
</div>

View File

@@ -49,9 +49,9 @@ export default function GraphQLCustomizations() {
size="small"
startIcon={<PlusIcon />}
onClick={() => setIsOpen(true)}
className="mt-2"
className="mt-2 px-2"
>
Add GQL Customization
Add GraphQL Customization
</Button>
</Box>
);
@@ -95,7 +95,10 @@ export default function GraphQLCustomizations() {
</Tooltip>
</Box>
<Input
{...register('definition.customization.root_fields_namespace')}
{...register('definition.customization.root_fields_namespace', {
setValueAs: (v) =>
typeof v === 'string' && v.trim() === '' ? undefined : v,
})}
id="definition.customization.root_fields_namespace"
name="definition.customization.root_fields_namespace"
placeholder="namespace_"

View File

@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/v2/Input';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import { Button as ButtonV3 } from '@/components/ui/v3/button';
@@ -29,7 +30,7 @@ import type {
RemoteSchemaCustomizationFieldNamesItem,
} from '@/utils/hasura-api/generated/schemas';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Controller,
useFieldArray,
@@ -45,6 +46,7 @@ export interface EditGraphQLCustomizationsProps {
export default function EditGraphQLCustomizations({
remoteSchemaName,
}: EditGraphQLCustomizationsProps) {
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading, error } =
useIntrospectRemoteSchemaQuery(remoteSchemaName);
@@ -189,12 +191,40 @@ export default function EditGraphQLCustomizations({
);
}
return (
<Box className="space-y-4">
<Box className="flex flex-row items-center space-x-2">
if (!isOpen) {
return (
<Box className="space-y-4">
<Text variant="h4" className="text-lg font-semibold">
GraphQL Customizations
</Text>
<Button
variant="outlined"
color="primary"
size="small"
startIcon={<PencilIcon />}
onClick={() => setIsOpen(true)}
className="mt-2 px-2"
>
Edit GraphQL Customization
</Button>
</Box>
);
}
return (
<Box className="space-y-4">
<Box className="flex flex-row items-center justify-between">
<Text variant="h4" className="text-lg font-semibold">
GraphQL Customizations
</Text>
<Button
variant="outlined"
color="secondary"
size="small"
onClick={() => setIsOpen(false)}
>
Close
</Button>
</Box>
<Box className="space-y-4 rounded border-1 p-4">
@@ -206,7 +236,10 @@ export default function EditGraphQLCustomizations({
</Tooltip>
</Box>
<Input
{...register('definition.customization.root_fields_namespace')}
{...register('definition.customization.root_fields_namespace', {
setValueAs: (v) =>
typeof v === 'string' && v.trim() === '' ? undefined : v,
})}
id="definition.customization.root_fields_namespace"
name="definition.customization.root_fields_namespace"
placeholder="namespace_"
@@ -310,10 +343,10 @@ export default function EditGraphQLCustomizations({
)}
/>
</Box>
<Box className="flex items-end justify-end md:justify-start">
<Box className="flex">
<Button
variant="borderless"
className="col-span-1"
className="h-10 self-end"
color="error"
onClick={() => removeTypeRemap(fromType)}
>
@@ -480,10 +513,10 @@ export default function EditGraphQLCustomizations({
fullWidth
/>
</Box>
<Box className="flex items-end justify-end md:justify-start">
<Box className="flex">
<Button
variant="borderless"
className="col-span-1"
className="h-10 self-end"
color="error"
onClick={() => removeFieldName(index)}
>
@@ -492,42 +525,46 @@ export default function EditGraphQLCustomizations({
</Box>
</Box>
<Box className="mt-3 space-y-2">
<Text className="font-medium">Field remaps</Text>
<Box className="space-y-2">
{fields.map((f) => {
const key = f.name;
return (
<Box
key={key}
className="grid grid-cols-1 gap-2 md:grid-cols-2"
>
<Input
disabled
value={key}
variant="inline"
fullWidth
/>
<Controller
control={control}
name={`definition.customization.field_names.${index}.mapping.${key}`}
render={({ field }) => (
<Input
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
placeholder="new_field_name"
hideEmptyHelperText
autoComplete="off"
variant="inline"
fullWidth
/>
)}
/>
</Box>
);
})}
{fields.length > 0 && (
<Box className="mt-3 space-y-2">
<Text className="font-medium">Field remaps</Text>
<Box className="space-y-2">
{fields.map((f) => {
const key = f.name;
return (
<Box
key={key}
className="grid grid-cols-1 gap-2 md:grid-cols-2"
>
<Input
disabled
value={key}
variant="inline"
fullWidth
/>
<Controller
control={control}
name={`definition.customization.field_names.${index}.mapping.${key}`}
render={({ field }) => (
<Input
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value)
}
placeholder="new_field_name"
hideEmptyHelperText
autoComplete="off"
variant="inline"
fullWidth
/>
)}
/>
</Box>
);
})}
</Box>
</Box>
</Box>
)}
</Box>
);
})}

View File

@@ -1,9 +1,11 @@
import { Input } from '@/components/ui/v2/Input';
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
import { Input } from '@/components/ui/v3/input';
import useIntrospectRemoteSchemaQuery from '@/features/orgs/projects/remote-schemas/hooks/useIntrospectRemoteSchemaQuery/useIntrospectRemoteSchemaQuery';
import convertIntrospectionToSchema from '@/features/orgs/projects/remote-schemas/utils/convertIntrospectionToSchema';
import { Search, X } from 'lucide-react';
import React, { useMemo, useRef, useState } from 'react';
import { getToastStyleProps } from '@/utils/constants/settings';
import { SearchIcon, XIcon } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import type { RemoteSchemaTreeRef } from './RemoteSchemaTree';
import { RemoteSchemaTree } from './RemoteSchemaTree';
@@ -23,6 +25,11 @@ export default function RemoteSchemaPreview({
const [searchTerm, setSearchTerm] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [matches, setMatches] = useState<string[][]>([]);
const [matchIndex, setMatchIndex] = useState(0);
const [hasSearched, setHasSearched] = useState(false);
const treeContainerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const schema = useMemo(() => {
if (introspectionData) {
@@ -31,6 +38,18 @@ export default function RemoteSchemaPreview({
return null;
}, [introspectionData]);
const navigateToMatch = async (paths: string[][], index: number) => {
if (!treeRef.current || paths.length === 0) {
return;
}
const path = paths[index];
await treeRef.current.expandToItem(path);
const foundItemId = path[path.length - 1];
treeRef.current.selectItems([foundItemId]);
treeRef.current.focusItem(foundItemId);
};
const handleSearch = async (e?: React.FormEvent) => {
e?.preventDefault();
@@ -41,35 +60,60 @@ export default function RemoteSchemaPreview({
setIsSearching(true);
try {
const path = treeRef.current.findItemPath(searchTerm);
if (path && path.length > 0) {
await treeRef.current.expandToItem(path);
const foundItemId = path[path.length - 1];
treeRef.current.selectItems([foundItemId]);
treeRef.current.focusItem(foundItemId);
const allPaths = treeRef.current.findAllItemPaths(searchTerm);
setMatches(allPaths);
setHasSearched(true);
if (allPaths.length > 0) {
setMatchIndex(0);
await navigateToMatch(allPaths, 0);
}
} catch (searchError) {
console.error('Search error:', searchError);
toast.error(
searchError?.message || 'An error occurred. Please try again.',
getToastStyleProps(),
);
} finally {
setIsSearching(false);
}
};
const handleClearSearch = () => {
setSearchTerm('');
treeRef.current?.focusTree();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
} else if (e.key === 'Escape') {
handleClearSearch();
if (e.key === 'Enter' && matches.length > 0) {
e.preventDefault();
const step = e.shiftKey ? -1 : 1;
const nextIndex = (matchIndex + step + matches.length) % matches.length;
setMatchIndex(nextIndex);
navigateToMatch(matches, nextIndex);
}
};
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
return;
}
const { target } = e;
const inTree =
treeContainerRef.current?.contains(target as Node) ?? false;
const isNotSearchInput = target !== searchInputRef.current;
if (!inTree || !isNotSearchInput) {
return;
}
if (matches.length === 0) {
return;
}
e.preventDefault();
e.stopPropagation();
const step = e.shiftKey ? -1 : 1;
const nextIndex = (matchIndex + step + matches.length) % matches.length;
setMatchIndex(nextIndex);
navigateToMatch(matches, nextIndex);
};
window.addEventListener('keydown', onKeyDown, { capture: true });
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [matches, matchIndex]);
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
@@ -100,8 +144,8 @@ export default function RemoteSchemaPreview({
}
return (
<div className="rounded-lg border">
<div className="border-b bg-muted/50 p-4">
<div className="rounded-lg border bg-card">
<div className="border-b p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">Schema Preview</h3>
@@ -112,45 +156,66 @@ export default function RemoteSchemaPreview({
</div>
<form onSubmit={handleSearch} className="flex w-full gap-2">
<div className="relative flex w-full flex-1 flex-row items-center">
<Search className="h-4 w-4 text-muted-foreground" />
<div className="relative max-w-xs flex-1 text-foreground">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3 text-muted-foreground peer-disabled:opacity-50">
<SearchIcon className="z-10 h-4 w-4" />
<span className="sr-only">Search</span>
</div>
<Input
type="search"
value={searchTerm}
fullWidth
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => {
setSearchTerm(e.target.value);
setMatches([]);
setMatchIndex(0);
setHasSearched(false);
}}
onKeyDown={handleKeyDown}
placeholder="Search fields, types, or operations..."
className="max-w-72 pl-4"
ref={searchInputRef}
disabled={isSearching}
placeholder="Search fields, types, or operations..."
className="peer px-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
/>
{searchTerm && (
<button
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 transform text-muted-foreground hover:text-foreground"
aria-label="Clear search"
onClick={() => {
setSearchTerm('');
setMatches([]);
setMatchIndex(0);
}}
className="absolute inset-y-0 right-0 rounded-l-none text-muted-foreground hover:bg-transparent focus-visible:ring-ring/50"
>
<X className="h-4 w-4" />
</button>
<XIcon className="h-4 w-4" />
<span className="sr-only">Clear input</span>
</Button>
)}
</div>
<Button
type="submit"
variant="secondary"
size="sm"
disabled={!searchTerm.trim() || isSearching}
loading={isSearching}
>
{isSearching ? 'Searching...' : 'Find'}
</Button>
{hasSearched && (
<span className="flex items-center text-xs text-muted-foreground">
{matches.length}{' '}
{matches.length === 1 ? 'occurrence' : 'occurrences'} found
</span>
)}
</form>
</div>
<div className="p-4">
<div className="p-4" ref={treeContainerRef}>
<RemoteSchemaTree
ref={treeRef}
className="min-h-[400px]"
schema={schema}
highlightTerm={searchTerm}
/>
</div>
</div>

View File

@@ -205,43 +205,45 @@ describe('RemoteSchemaTree', () => {
).toBeInTheDocument();
});
it('findItemPath returns the path to the first matching item', () => {
it('findAllItemPaths returns paths to matching items', () => {
const ref = React.createRef<RemoteSchemaTreeRef>();
render(<RemoteSchemaTree ref={ref} schema={schema} />);
expect(ref.current).toBeTruthy();
const helloPath = ref.current!.findItemPath('hello');
expect(helloPath?.[helloPath.length - 1]).toEqual('__query.field.hello');
const helloPaths = ref.current!.findAllItemPaths('hello');
expect(helloPaths[0]?.[helloPaths[0].length - 1]).toEqual(
'__query.field.hello',
);
const postByIdPath = ref.current!.findItemPath('postById');
expect(postByIdPath?.[postByIdPath.length - 1]).toEqual(
const postByIdPaths = ref.current!.findAllItemPaths('postById');
expect(postByIdPaths[0]?.[postByIdPaths[0].length - 1]).toEqual(
'__subscription.field.postById',
);
});
it('findItemPath is case-insensitive and returns full path including parents', () => {
it('findAllItemPaths is case-insensitive and returns full path including parents', () => {
const ref = React.createRef<RemoteSchemaTreeRef>();
render(<RemoteSchemaTree ref={ref} schema={schema} />);
const helloPath = ref.current!.findItemPath('HELLO');
expect(helloPath).toEqual(['root', '__query', '__query.field.hello']);
const helloPaths = ref.current!.findAllItemPaths('HELLO');
expect(helloPaths[0]).toEqual(['root', '__query', '__query.field.hello']);
});
it('findItemPath can match a parent item (e.g., Mutation)', () => {
it('findAllItemPaths can match a parent item (e.g., Mutation)', () => {
const ref = React.createRef<RemoteSchemaTreeRef>();
render(<RemoteSchemaTree ref={ref} schema={schema} />);
const mutationPath = ref.current!.findItemPath('Mutation');
expect(mutationPath).toEqual(['root', '__mutation']);
const mutationPaths = ref.current!.findAllItemPaths('Mutation');
expect(mutationPaths[0]).toEqual(['root', '__mutation']);
});
it('findItemPath returns null when no item matches', () => {
it('findAllItemPaths returns empty array when no item matches', () => {
const ref = React.createRef<RemoteSchemaTreeRef>();
render(<RemoteSchemaTree ref={ref} schema={schema} />);
const noMatch = ref.current!.findItemPath('THIS_DOES_NOT_EXIST');
expect(noMatch).toBeNull();
const noMatches = ref.current!.findAllItemPaths('THIS_DOES_NOT_EXIST');
expect(noMatches).toEqual([]);
});
it('renders correctly when schema uses custom root type names', async () => {

View File

@@ -15,8 +15,9 @@ import {
type TreeRef,
} from 'react-complex-tree';
import 'react-complex-tree/lib/style-modern.css';
import getText from './getText';
import type { ComplexTreeData } from './types';
import { buildComplexTreeData } from './utils';
import { buildComplexTreeData, highlightNode } from './utils';
export interface RemoteSchemaTreeProps {
/**
@@ -24,10 +25,11 @@ export interface RemoteSchemaTreeProps {
*/
schema: GraphQLSchema;
className?: string;
highlightTerm?: string;
}
export interface RemoteSchemaTreeRef {
findItemPath: (searchTerm: string) => string[] | null;
findAllItemPaths: (searchTerm: string) => string[][];
expandToItem: (path: string[]) => Promise<void>;
focusItem: (itemId: string) => void;
selectItems: (itemIds: string[]) => void;
@@ -37,7 +39,7 @@ export interface RemoteSchemaTreeRef {
export const RemoteSchemaTree = forwardRef<
RemoteSchemaTreeRef,
RemoteSchemaTreeProps
>(({ schema, className }, ref) => {
>(({ schema, className, highlightTerm }, ref) => {
const treeRef = useRef<TreeRef<string | React.ReactNode>>(null);
const theme = useTheme();
@@ -54,50 +56,34 @@ export const RemoteSchemaTree = forwardRef<
const [expandedItems, setExpandedItems] = useState<string[]>(['root']);
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const findItemPath = useCallback(
(searchTerm: string, searchRoot = 'root'): string[] | null => {
const findAllItemPaths = useCallback(
(searchTerm: string, searchRoot = 'root'): string[][] => {
const lowerSearchTerm = searchTerm.toLowerCase();
const getText = (data: any): string => {
if (React.isValidElement<{ children?: React.ReactNode }>(data)) {
const { children } = data.props;
if (Array.isArray(children)) {
return children.map(getText).join('');
}
return getText(children);
}
if (typeof data === 'string' || typeof data === 'number') {
return String(data);
}
return String(data);
};
const searchInItem = (
const collectInItem = (
itemId: string,
currentPath: string[],
): string[] | null => {
acc: string[][],
): void => {
const item = treeData[itemId];
if (!item) {
return null;
return;
}
const searchableText = getText(item.data);
if (searchableText.toLowerCase().includes(lowerSearchTerm)) {
return [...currentPath, itemId];
acc.push([...currentPath, itemId]);
}
let foundPath: string[] | null = null;
// eslint-disable-next-line no-restricted-syntax
for (const childId of item.children ?? []) {
foundPath = searchInItem(childId, [...currentPath, itemId]);
if (foundPath) {
break;
}
collectInItem(childId, [...currentPath, itemId], acc);
}
return foundPath;
};
return searchInItem(searchRoot, []);
const results: string[][] = [];
collectInItem(searchRoot, [], results);
return results;
},
[treeData],
);
@@ -114,7 +100,7 @@ export const RemoteSchemaTree = forwardRef<
useImperativeHandle(
ref,
() => ({
findItemPath,
findAllItemPaths,
expandToItem,
focusItem: (itemId: string) => {
setFocusedItem(itemId);
@@ -128,7 +114,7 @@ export const RemoteSchemaTree = forwardRef<
treeRef.current?.focusTree();
},
}),
[findItemPath],
[findAllItemPaths],
);
const getItemTitle = (item: any) => {
@@ -157,6 +143,9 @@ export const RemoteSchemaTree = forwardRef<
<ControlledTreeEnvironment
items={treeData}
getItemTitle={getItemTitle}
renderItemTitle={({ title }) => (
<span>{highlightNode(title, highlightTerm)}</span>
)}
viewState={{
'schema-tree': {
focusedItem,
@@ -174,6 +163,7 @@ export const RemoteSchemaTree = forwardRef<
)
}
onSelectItems={(items) => setSelectedItems(items.map(String))}
canSearch={false}
>
<Tree
ref={treeRef}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import getText from './getText';
function Icon() {
return React.createElement('svg');
}
describe('getText', () => {
it('returns the same string for plain string inputs (e.g., root labels)', () => {
expect(getText('Schema')).toBe('Schema');
expect(getText('Query')).toBe('Query');
});
it('converts numbers to strings', () => {
expect(getText(123)).toBe('123');
});
it('handles argument-like input with type annotation', () => {
expect(getText('amount: Int!')).toBe('amount: Int!');
});
it('returns empty string for a React element with no children', () => {
const element = React.createElement('span', null);
expect(getText(element)).toBe('');
});
it('flattens array children like the field item structure in the tree (icon + span text)', () => {
const fieldElement = React.createElement(
'div',
{ className: 'flex items-center gap-1', style: { color: '#6b7280' } },
[
React.createElement(Icon, { key: 'icon', className: 'text-gray-400' }),
React.createElement('span', { key: 'text' }, 'Slug: String!'),
],
);
expect(getText(fieldElement)).toBe('Slug: String!');
});
});

View File

@@ -0,0 +1,20 @@
import { isEmptyValue } from '@/lib/utils';
import React from 'react';
export default function getText(data: any): string {
if (React.isValidElement<{ children?: React.ReactNode }>(data)) {
const { children } = data.props;
if (Array.isArray(children)) {
return children.map(getText).join('');
}
if (isEmptyValue(children)) {
return '';
}
return getText(children);
}
if (typeof data === 'string' || typeof data === 'number') {
return String(data);
}
return String(data);
}

View File

@@ -1,3 +1,4 @@
import highlightMatch from '@/features/orgs/utils/highlightMatch/highlightMatch';
import type { GraphQLSchema } from 'graphql';
import { HelpCircle } from 'lucide-react';
import React from 'react';
@@ -221,3 +222,34 @@ export const buildComplexTreeData = ({
return treeData;
};
export const highlightNode = (
node: React.ReactNode,
term?: string,
): React.ReactNode => {
const search = term?.trim();
if (!search) {
return node;
}
if (typeof node === 'string' || typeof node === 'number') {
const text = String(node);
return highlightMatch(text, search);
}
if (Array.isArray(node)) {
return React.Children.map(node as React.ReactNode[], (child) =>
highlightNode(child, search),
);
}
if (React.isValidElement(node)) {
const childProps: any = {};
if (node.props && 'children' in node.props) {
childProps.children = highlightNode(node.props.children, search);
}
return React.cloneElement(node, childProps);
}
return node;
};

View File

@@ -0,0 +1,43 @@
function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export default function highlightMatch(text: string, query: string) {
if (!query) {
return text;
}
const safe = escapeRegExp(query);
const regex = new RegExp(safe, 'gi');
const nodes: Array<JSX.Element> = [];
let lastIndex = 0;
let match: RegExpExecArray | null = regex.exec(text);
while (match) {
const start = match.index;
const end = start + match[0].length;
if (start > lastIndex) {
const chunk = text.slice(lastIndex, start);
nodes.push(<span key={`seg-${lastIndex}`}>{chunk}</span>);
}
nodes.push(
<mark
key={`hit-${start}`}
className="rounded bg-yellow-300/60 px-0.5 dark:bg-yellow-200/60"
>
{match[0]}
</mark>,
);
lastIndex = end;
match = regex.exec(text);
}
if (lastIndex < text.length) {
nodes.push(<span key={`seg-${lastIndex}`}>{text.slice(lastIndex)}</span>);
}
return nodes;
}

View File

@@ -0,0 +1 @@
export { default as highlightMatch } from './highlightMatch';

View File

@@ -195,7 +195,8 @@
"icon": "book",
"pages": [
"/products/graphql/guides/react-apollo",
"/products/graphql/guides/react-query"
"/products/graphql/guides/react-query",
"/products/graphql/guides/react-urql"
]
}
]
@@ -278,7 +279,17 @@
"/platform/cli/migrate-config",
"/platform/cli/multiple-projects",
"/platform/cli/configuration-overlays",
"/platform/cli/seeds"
"/platform/cli/seeds",
{
"group": "MCP Server",
"icon": "brain",
"pages": [
"/platform/cli/mcp/overview",
"/platform/cli/mcp/configuration",
"/platform/cli/mcp/clients",
"/platform/cli/mcp/troubleshooting"
]
}
]
},
{

View File

@@ -0,0 +1,31 @@
---
title: Clients
description: Configuration examples for various MCP clients
icon: rocket
---
The Nhost MCP server can be accessed through any client that supports the Model Context Protocol (MCP). Below are examples of how to configure and use various MCP clients.
#### Cursor
1. Go to "Cursor Settings"
2. Click on "MCP"
3. Click on "+ Add new global MCP server"
4. Add the following object inside `"mcpServers"`:
```json
"mcp-nhost": {
"command": "nhost",
"args": [
"mcp", "start",
],
}
```
#### Claude Code
Run the command:
```
claude mcp add nhost nhost mcp start
```

View File

@@ -0,0 +1,189 @@
---
title: Configuration
description: Configuration options for the Nhost MCP tool
icon: wrench
---
This document describes all available configuration options for the Nhost MCP tool. The configuration file uses TOML format and supports environment variable interpolation.
## Configuration File Location
The configuration file is located at `.nhost/mcp-nhost.toml` in your project directory. You can specify a custom location using the `--config-file` flag.
## TOML
```toml
# Cloud configuration for managing Nhost Cloud projects and organizations
# Remove this section to disable Nhost Cloud access
[cloud]
# Enable mutations on Nhost Cloud (project configuration, organization management, etc.)
# When false, only queries are allowed
# Requires your personal access token from CLI credentials
enable_mutations = true
# Project-specific configurations
# You can configure multiple projects (local or cloud)
[[projects]]
# Project subdomain (required)
# For local projects, use "local"
subdomain = "your-project-subdomain"
# Project region (required)
# For local projects, use "local"
region = "your-project-region"
# Optional: Project description for better identification
description = "Production project for my app"
# Authentication: Use either admin_secret or pat
# Admin secret for project access
admin_secret = "your-project-admin-secret"
# OR
# Project-specific PAT (Personal Access Token)
pat = "your-project-pat"
# Enable metadata management (migrations, permissions, etc.)
# Requires admin_secret to be set
# When enabled, the manage-graphql tool can create migrations and manage Hasura metadata
manage_metadata = false
# List of allowed GraphQL queries
# Use ["*"] to allow all queries
# Use [] to disable all queries
# Use specific query names to allow only those queries
allow_queries = ["*"]
# List of allowed GraphQL mutations
# Use ["*"] to allow all mutations
# Use [] to disable all mutations
# Use specific mutation names to allow only those mutations
allow_mutations = ["*"]
# Optional: Custom GraphQL URL
# Default: https://{subdomain}.graphql.{region}.nhost.run/v1
# For local: https://local.graphql.local.nhost.run/v1
graphql_url = "your-custom-url"
# Optional: Custom Auth URL
# Default: https://{subdomain}.auth.{region}.nhost.run/v1
# For local: https://local.auth.local.nhost.run/v1
auth_url = "your-custom-url"
# Optional: Custom Hasura URL (for metadata management)
# Default: https://{subdomain}.hasura.{region}.nhost.run
# For local: https://local.hasura.local.nhost.run
hasura_url = "your-custom-url"
```
## Example Configurations
### Basic Configuration with Cloud Access
```toml
# Enable Nhost Cloud management with mutations
[cloud]
enable_mutations = true
# Configure a production project
[[projects]]
subdomain = "my-app"
region = "eu-central-1"
description = "Production project"
pat = "nhp_production_pat"
allow_queries = ["*"]
allow_mutations = ["insertPost", "updatePost", "deletePost"]
```
### Local Development Configuration
```toml
# Configure local development environment
[[projects]]
subdomain = "local"
region = "local"
description = "Local development project running via the Nhost CLI"
admin_secret = "nhost-admin-secret"
manage_metadata = true
allow_queries = ["*"]
allow_mutations = ["*"]
```
### Multiple Projects with Different Access Levels
```toml
# Enable cloud management
[cloud]
enable_mutations = true
# Local development with full access
[[projects]]
subdomain = "local"
region = "local"
description = "Local development project running via the Nhost CLI"
admin_secret = "nhost-admin-secret"
manage_metadata = true
allow_queries = ["*"]
allow_mutations = ["*"]
# Production project with restricted access
[[projects]]
subdomain = "my-app-prod"
region = "eu-central-1"
description = "Production project"
pat = "nhp_prod_pat"
allow_queries = ["*"]
allow_mutations = ["insertPost", "updatePost"]
# Staging project with read-only access
[[projects]]
subdomain = "my-app-staging"
region = "eu-central-1"
description = "Staging project"
pat = "nhp_staging_pat"
allow_queries = ["*"]
allow_mutations = []
```
### Environment Variable Interpolation
You can use environment variables in your configuration:
```toml
[cloud]
enable_mutations = true
[[projects]]
subdomain = "my-app"
region = "eu-central-1"
description = "Production project"
# Use ${VAR_NAME} syntax to interpolate environment variables
admin_secret = "${NHOST_ADMIN_SECRET}"
allow_queries = ["*"]
allow_mutations = ["*"]
```
## Configuration Options Reference
### Cloud Section
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `enable_mutations` | boolean | No | Enable mutations on Nhost Cloud. Defaults to `false`. Requires CLI credentials with PAT. |
### Projects Section
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `subdomain` | string | Yes | Project subdomain. Use `"local"` for local development projects. |
| `region` | string | Yes | Project region. Use `"local"` for local development projects. |
| `description` | string | No | Human-readable description of the project. |
| `admin_secret` | string | No* | Admin secret for authentication. Either `admin_secret` or `pat` is required. |
| `pat` | string | No* | Project-specific Personal Access Token. Either `admin_secret` or `pat` is required. |
| `manage_metadata` | boolean | No | Enable metadata management (migrations, permissions, etc.). Requires `admin_secret`. Defaults to `false`. |
| `allow_queries` | array | No | List of allowed query names. Use `["*"]` for all, `[]` for none. Defaults to `[]`. |
| `allow_mutations` | array | No | List of allowed mutation names. Use `["*"]` for all, `[]` for none. Defaults to `[]`. |
| `graphql_url` | string | No | Custom GraphQL endpoint URL. Auto-generated if not provided. |
| `auth_url` | string | No | Custom Auth endpoint URL. Auto-generated if not provided. |
| `hasura_url` | string | No | Custom Hasura endpoint URL. Auto-generated if not provided. Required for `manage_metadata`. |
\* Either `admin_secret` or `pat` must be provided for authentication.

View File

@@ -0,0 +1,98 @@
---
title: Overview
description: A Model Context Protocol (MCP) server implementation for interacting with Nhost projects and services.
icon: house
---
The Nhost cli ships with an MCP server that lets you interact with your Nhost projects through AI assistants using the Model Context Protocol. It provides secure, controlled access to your GraphQL data, project configuration, and documentation—with granular permissions that let you specify exactly which queries and mutations an LLM can execute. For development, it streamlines your workflow by enabling AI-assisted schema management, metadata changes, and migrations, while providing direct access to your GraphQL schema for intelligent query building.
## Overview
The MCP server is designed to provide a unified interface for managing Nhost projects through the Model Context Protocol. It enables seamless interaction with Nhost Cloud services, offering a robust set of tools for project management and configuration.
## Available Tools
The following tools are currently exposed through the MCP interface:
1. **cloud-graphql-query**
- Executes GraphQL queries and mutations against the Nhost Cloud platform
- Allows querying and updating project configurations running on Nhost Cloud
- Schema available via `schema://nhost-cloud` resource
2. **graphql-query**
- Executes GraphQL queries against Nhost projects
- Enables interaction with project data
- Supports both queries and mutations (must be allowed in configuration)
- Requires specifying subdomain, role, and optionally userId
- Role parameter is required and affects the schema
- Schema available via `get-schema` tool
3. **manage-graphql**
- Interacts with GraphQL's management endpoints for Nhost projects
- Manages Hasura metadata, migrations, permissions, and remote schemas
- Requires admin secret and `manage_metadata` enabled in configuration
- Schema available via `schema://graphql-management` resource
4. **get-schema**
- Retrieves the GraphQL schema for Nhost projects
- Provides access to project-specific queries and mutations
5. **search**
- Searches Nhost's official documentation
- Provides information about Nhost features, APIs, and guides
- Helps find relevant documentation for implementing features or solving issues
- Returns links to detailed documentation pages
## Available Resources
The MCP server exposes the following resources:
1. **schema://nhost-cloud**
- GraphQL schema for the Nhost Cloud platform
- Schema includes mutations only if `enable_mutations` is enabled (see CONFIG section)
2. **schema://graphql-management**
- GraphQL management schema for Nhost projects
- Useful for understanding how to manage Hasura metadata, migrations, permissions, and remote schemas
- Available only when `manage_metadata` is enabled for at least one project
3. **schema://nhost.toml**
- Cuelang schema for the nhost.toml configuration file
## Security and Privacy
### Enhanced Protection Layer
The MCP server is designed with security at its core, providing an additional protection layer beyond your existing GraphQL permissions. Key security features include:
- **Authentication enforcement** for all requests
- **Permission and role respect** based on your existing authorization system and the credentials provided
- **Query/mutation filtering** to further restrict allowed operations
### Granular Access Control
One of the MCP server's key security advantages is the ability to specify exactly which operations can pass through, even for authenticated users:
```toml
[[projects]]
subdomain = "my-blog"
region = "eu-central-1"
pat = "nhp_project_specific_pat"
allow_queries = ["getBlogs", "getComments"]
allow_mutations = ["insertBlog", "insertComment"]
```
With the configuration above, an LLM will be able to only execute the queries and mutations above on behalf of a user even if the user has broader permissions in the Nhost project.
Additionally, for local development projects, you can enable metadata management:
```toml
[[projects]]
subdomain = "local"
admin_secret = "nhost-admin-secret"
manage_metadata = true
```
This allows the LLM to create migrations, manage permissions, and handle schema changes through the `manage-graphql` tool.
For more details, see the [configuration](./configuration) page.

View File

@@ -0,0 +1,28 @@
---
title: Troubleshooting
description: Tips for troubleshooting issues with the Nhost MCP server
icon: arrow-progress
---
If you run into issues using the MCP server on your favorite AI service you can try running the tools yourself. For example:
```bash
# cloud-graphql-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"cloud-graphql-query","arguments":{"query":"{ apps { id subdomain name } }"}},"id":1}' | nhost mcp start
# graphql-query
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"graphql-query","arguments":{"query":"{ users { id } }","subdomain":"local","role":"admin"}},"id":1}' | nhost mcp start
# manage-graphql
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"manage-graphql","arguments":{"body":"{\"type\":\"export_metadata\",\"args\":{}}","subdomain":"local","path":"/v1/metadata"}},"id":1}' | nhost mcp start
# get-schema
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get-schema","arguments":{"subdomain":"local","role":"admin","summary":true}},"id":1}' | nhost mcp start
# search
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search","arguments":{"query":"how to enable magic links"}},"id":1}' | nhost mcp start
# Read resource
echo '{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"schema://nhost-cloud"},"id":1}' | nhost mcp start
```

View File

@@ -0,0 +1,9 @@
---
title: "React urql"
description: "How to use Nhost with React and url"
icon: U
---
You can use Nhost with [urql](https://nearform.com/open-source/urql/docs/) in your React applications. Urql is a highly customizable and versatile GraphQL client that makes it easy to fetch, cache, and manage GraphQL data in your React applications.
You can find a working example with instructions in our [GitHub repository](https://github.com/nhost/nhost/blob/main/examples/guides/react-urql/README.md).

View File

@@ -254,7 +254,7 @@ Start local development environment
**--ca-certificates**="": Mounts and everrides path to CA certificates in the containers
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.0)
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.4)
**--disable-tls**: Disable TLS
@@ -284,7 +284,7 @@ Start local development environment connected to an Nhost Cloud project (BETA)
**--ca-certificates**="": Mounts and everrides path to CA certificates in the containers
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.0)
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.4)
**--disable-tls**: Disable TLS

View File

@@ -28,7 +28,7 @@ httpPoolSize = 100
[functions]
[functions.node]
version = 20
version = 22
[auth]
version = '0.41.1'
@@ -74,7 +74,7 @@ expiresIn = 2592000
enabled = false
[auth.method.emailPasswordless]
enabled = true
enabled = false
[auth.method.otp]
[auth.method.otp.email]
@@ -90,12 +90,7 @@ enabled = false
[auth.method.oauth]
[auth.method.oauth.apple]
enabled = true
teamId = '{{ secrets.APPLE_TEAM_ID }}'
clientId = '{{ secrets.APPLE_CLIENT_ID }}'
audience = '{{ secrets.APPLE_AUDIENCE }}'
keyId = '{{ secrets.APPLE_KEY_ID }}'
privateKey = '{{ secrets.APPLE_PRIVATE_KEY }}'
enabled = false
[auth.method.oauth.azuread]
tenant = 'common'
@@ -107,13 +102,15 @@ enabled = false
[auth.method.oauth.discord]
enabled = false
[auth.method.oauth.entraid]
tenant = 'common'
enabled = false
[auth.method.oauth.facebook]
enabled = false
[auth.method.oauth.github]
enabled = true
clientId = '{{ secrets.GITHUB_CLIENT_ID }}'
clientSecret = '{{ secrets.GITHUB_CLIENT_SECRET }}'
enabled = false
[auth.method.oauth.gitlab]
enabled = false
@@ -143,19 +140,13 @@ enabled = false
enabled = false
[auth.method.webauthn]
enabled = true
[auth.method.webauthn.relyingParty]
id = 'localhost'
name = 'nhost-sdk-experiment'
origins = ['http://localhost:3000', 'http://localhost:5173']
enabled = false
[auth.method.webauthn.attestation]
timeout = 60000
[auth.totp]
enabled = true
issuer = 'new-sdk'
enabled = false
[auth.misc]
concealErrors = false
@@ -166,23 +157,23 @@ limit = 10
interval = '1h'
[auth.rateLimit.sms]
limit = 100
limit = 10
interval = '1h'
[auth.rateLimit.bruteForce]
limit = 100
limit = 10
interval = '5m'
[auth.rateLimit.signups]
limit = 100
limit = 10
interval = '5m'
[auth.rateLimit.global]
limit = 1000
limit = 100
interval = '1m'
[postgres]
version = '17.5-20250728-1'
version = '14.18-20250728-1'
[postgres.resources]
[postgres.resources.storage]
@@ -191,7 +182,7 @@ capacity = 1
[provider]
[storage]
version = '0.8.0-beta3'
version = '0.7.2'
[observability]
[observability.grafana]

View File

@@ -21,6 +21,8 @@ let
"${submodule}/react-apollo/pnpm-lock.yaml"
"${submodule}/react-query/package.json"
"${submodule}/react-query/pnpm-lock.yaml"
"${submodule}/react-urql/package.json"
"${submodule}/react-urql/pnpm-lock.yaml"
];
};

25
examples/guides/react-urql/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite

View File

@@ -0,0 +1,453 @@
# React with urql and Nhost SDK
This guide demonstrates how to integrate GraphQL queries and mutations with React using urql and the Nhost SDK.
## Setup
### 1. Install Dependencies
```bash
npm install urql @urql/exchange-auth @nhost/nhost-js graphql @graphql-typed-document-node/core
# or
yarn add urql @urql/exchange-auth @nhost/nhost-js graphql @graphql-typed-document-node/core
# or
pnpm add urql @urql/exchange-auth @nhost/nhost-js graphql @graphql-typed-document-node/core
```
### 2. Install GraphQL CodeGen for Development
```bash
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node @graphql-codegen/schema-ast
# or
yarn add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node @graphql-codegen/schema-ast
# or
pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node @graphql-codegen/schema-ast
```
### 3. Configure GraphQL CodeGen
Create a `codegen.ts` file in your project root:
```typescript
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: [
{
"https://local.graphql.local.nhost.run/v1": {
headers: {
"x-hasura-admin-secret": "nhost-admin-secret",
},
},
},
],
documents: ["src/**/*.ts"],
ignoreNoDocuments: true,
generates: {
"./src/lib/graphql/__generated__/graphql.ts": {
documents: ["src/lib/graphql/**/*.graphql"],
plugins: ["typescript", "typescript-operations", "typed-document-node"],
config: {
scalars: {
UUID: "string",
uuid: "string",
timestamptz: "string",
jsonb: "Record<string, any>",
bigint: "number",
bytea: "Buffer",
citext: "string",
},
useTypeImports: true,
},
},
"./schema.graphql": {
plugins: ["schema-ast"],
config: {
includeDirectives: true,
},
},
},
};
export default config;
```
Add a script to your `package.json`:
```json
{
"scripts": {
"generate": "graphql-codegen --config codegen.ts"
}
}
```
## Integration Guide
### 1. Create an Auth Provider
Create an authentication context to manage the user session:
```typescript
// src/lib/nhost/AuthProvider.tsx (see file for full code)
import { createClient, type NhostClient } from "@nhost/nhost-js";
import type { Session } from "@nhost/nhost-js/auth";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface AuthContextType {
user: Session["user"] | null;
session: Session | null;
isAuthenticated: boolean;
isLoading: boolean;
nhost: NhostClient;
}
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<Session["user"] | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
// Create the nhost client
const nhost = useMemo(
() =>
createClient({
region: import.meta.env.VITE_NHOST_REGION || "local",
subdomain: import.meta.env.VITE_NHOST_SUBDOMAIN || "local",
}),
[],
);
useEffect(() => {
setIsLoading(true);
const currentSession = nhost.getUserSession();
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
setIsLoading(false);
const unsubscribe = nhost.sessionStorage.onChange((currentSession) => {
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
});
return () => {
unsubscribe();
};
}, [nhost]);
const value: AuthContextType = {
user,
session,
isAuthenticated,
isLoading,
nhost,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
```
### 2. Create urql Provider
Set up the urql client with authentication:
```typescript
// src/lib/graphql/UrqlProvider.tsx
import { authExchange } from "@urql/exchange-auth";
import type { ReactNode } from "react";
import {
type Client,
cacheExchange,
createClient,
fetchExchange,
Provider,
} from "urql";
import { useAuth } from "../nhost/AuthProvider";
export const UrqlProvider = ({ children }: { children: ReactNode }) => {
const { nhost } = useAuth();
const client: Client = createClient({
url:
import.meta.env.VITE_NHOST_GRAPHQL_URL ||
"https://local.graphql.local.nhost.run/v1",
// Force POST requests (Hasura interprets GET requests as persisted queries)
preferGetMethod: false,
exchanges: [
cacheExchange,
authExchange(async (utils) => {
return {
addAuthToOperation(operation) {
const session = nhost.getUserSession();
if (!session?.accessToken) {
return operation;
}
return utils.appendHeaders(operation, {
Authorization: `Bearer ${session.accessToken}`,
});
},
didAuthError(error) {
return error.graphQLErrors.some((e) =>
e.message.includes("JWTExpired"),
);
},
async refreshAuth() {
const currentSession = nhost.getUserSession();
if (!currentSession?.refreshToken) {
return;
}
try {
await nhost.refreshSession(60);
} catch (e: unknown) {
console.error(
"Error refreshing session:",
e instanceof Error ? e : "Unknown error",
);
await nhost.auth.signOut({
refreshToken: currentSession.refreshToken,
});
}
},
};
}),
fetchExchange,
],
});
return <Provider value={client}>{children}</Provider>;
};
```
### 3. Set Up Your App Providers
Wrap your application with the Auth and urql providers:
```tsx
// src/main.tsx
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { UrqlProvider } from "./lib/graphql/UrqlProvider";
import { AuthProvider } from "./lib/nhost/AuthProvider";
const Root = () => (
<React.StrictMode>
<AuthProvider>
<UrqlProvider>
<App />
</UrqlProvider>
</AuthProvider>
</React.StrictMode>
);
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(<Root />);
```
### 4. Define GraphQL Operations
Create a GraphQL file with your queries and mutations:
```graphql
# src/lib/graphql/queries.graphql
query GetNinjaTurtlesWithComments {
ninjaTurtles {
id
name
description
createdAt
updatedAt
comments {
id
comment
createdAt
user {
id
displayName
email
}
}
}
}
mutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {
insertComment(object: { ninjaTurtleId: $ninjaTurtleId, comment: $comment }) {
id
comment
createdAt
ninjaTurtleId
}
}
```
### 5. Generate TypeScript Types
Run the code generator:
```bash
npm run generate
# or
yarn generate
# or
pnpm generate
```
This will generate typed document nodes in `src/lib/graphql/__generated__/graphql.ts`.
### 6. Use in Components
Use the generated document nodes with urql hooks in your components:
```tsx
// src/pages/Home.tsx
import { type JSX, useState } from "react";
import { useMutation, useQuery } from "urql";
import {
AddCommentDocument,
GetNinjaTurtlesWithCommentsDocument,
} from "../lib/graphql/__generated__/graphql";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Home(): JSX.Element {
const { isLoading } = useAuth();
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
const [commentText, setCommentText] = useState("");
// Query for data
const [{ data, fetching: loading, error }] = useQuery({
query: GetNinjaTurtlesWithCommentsDocument,
});
// Mutation hook
const [, addComment] = useMutation(AddCommentDocument);
if (isLoading) {
return <div>Loading...</div>;
}
const handleAddComment = async (turtleId: string) => {
if (!commentText.trim()) return;
const result = await addComment({
ninjaTurtleId: turtleId,
comment: commentText,
});
if (!result.error) {
setCommentText("");
setActiveCommentId(null);
}
};
if (loading) return <div>Loading ninja turtles...</div>;
if (error) return <div>Error: {error.message}</div>;
const ninjaTurtles = data?.ninjaTurtles || [];
return (
<div>
<h1>Ninja Turtles</h1>
{ninjaTurtles.map((turtle) => (
<div key={turtle.id}>
<h2>{turtle.name}</h2>
<p>{turtle.description}</p>
{/* Comments section */}
<div>
<h3>Comments ({turtle.comments.length})</h3>
{turtle.comments.map((comment) => (
<div key={comment.id}>
<p>{comment.comment}</p>
<small>
By{" "}
{comment.user?.displayName ||
comment.user?.email ||
"Anonymous"}
</small>
</div>
))}
{activeCommentId === turtle.id ? (
<div>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Add your comment..."
/>
<div>
<button onClick={() => setActiveCommentId(null)}>
Cancel
</button>
<button onClick={() => handleAddComment(turtle.id)}>
Submit
</button>
</div>
</div>
) : (
<button onClick={() => setActiveCommentId(turtle.id)}>
Add a comment
</button>
)}
</div>
</div>
))}
</div>
);
}
```
## Key Features
- **Type Safety**: Full TypeScript support with generated types from your GraphQL schema
- **Authentication**: Automatic token management with Nhost's auth exchange
- **Token Refresh**: Automatic JWT token refresh when expired
- **Caching**: Built-in document caching with urql's cache exchange
- **POST Requests**: Configured to use POST requests (Hasura compatibility)
## Important Configuration Notes
### Hasura Compatibility
When using urql with Hasura, it's important to set `preferGetMethod: false` in the client configuration. This is because:
- urql v5 defaults to using GET requests for queries (for better HTTP caching)
- Hasura interprets GET requests as persisted query attempts
- Setting `preferGetMethod: false` forces POST requests for all operations
### Authentication Exchange
The `authExchange` from `@urql/exchange-auth` handles:
- Adding the JWT access token to every request
- Detecting authentication errors (expired tokens)
- Automatically refreshing tokens when needed
- Signing out users when token refresh fails
This ensures seamless authenticated GraphQL requests throughout your application.

View File

@@ -0,0 +1,7 @@
{
"root": false,
"extends": "//",
"linter": {
"includes": ["**", "!src/lib/graphql/__generated__/graphql.ts"]
}
}

View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -e
echo "Running GraphQL code generator..."
pnpm graphql-codegen --config codegen.ts
GENERATED_TS_FILE="src/lib/graphql/__generated__/graphql.ts"
GENERATED_SCHEMA_FILE="schema.graphql"
if [ -f "$GENERATED_TS_FILE" ]; then
echo "Fixing import in $GENERATED_TS_FILE..."
echo "Formatting $GENERATED_TS_FILE..."
biome check --write "$GENERATED_TS_FILE"
else
echo "Error: Generated TypeScript file not found at $GENERATED_TS_FILE"
exit 1
fi
if [ -f "$GENERATED_SCHEMA_FILE" ]; then
echo "Formatting $GENERATED_SCHEMA_FILE..."
biome check --write "$GENERATED_SCHEMA_FILE"
echo "Successfully formatted $GENERATED_SCHEMA_FILE"
else
echo "Warning: Generated schema file not found at $GENERATED_SCHEMA_FILE"
fi
echo "All tasks completed successfully."

View File

@@ -0,0 +1,41 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: [
{
"https://local.graphql.local.nhost.run/v1": {
headers: {
"x-hasura-admin-secret": "nhost-admin-secret",
},
},
},
],
documents: ["src/**/*.ts"],
ignoreNoDocuments: true,
generates: {
"./src/lib/graphql/__generated__/graphql.ts": {
documents: ["src/lib/graphql/**/*.graphql"],
plugins: ["typescript", "typescript-operations", "typed-document-node"],
config: {
scalars: {
UUID: "string",
uuid: "string",
timestamptz: "string",
jsonb: "Record<string, any>",
bigint: "number",
bytea: "Buffer",
citext: "string",
},
useTypeImports: true,
},
},
"./schema.graphql": {
plugins: ["schema-ast"],
config: {
includeDirectives: true,
},
},
},
};
export default config;

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
{
"name": "demos/react-urql",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"generate": "bash codegen-wrapper.sh",
"test": "pnpm test:typecheck && pnpm test:lint",
"test:typecheck": "tsc --noEmit",
"test:lint": "biome check",
"format": "biome format --write",
"preview": "vite preview"
},
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"@nhost/nhost-js": "workspace:*",
"@urql/exchange-auth": "^3.0.0",
"graphql": "^16.11.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"urql": "^5.0.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.6",
"@graphql-codegen/schema-ast": "^4.1.0",
"@graphql-codegen/typed-document-node": "^6.0.2",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-operations": "^4.6.1",
"@types/node": "^22.15.17",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1"
}
}

3741
examples/guides/react-urql/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import type { JSX } from "react";
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Outlet,
Route,
RouterProvider,
} from "react-router-dom";
import Navigation from "./components/Navigation";
import ProtectedRoute from "./components/ProtectedRoute";
import Home from "./pages/Home";
import Profile from "./pages/Profile";
import SignIn from "./pages/SignIn";
import SignUp from "./pages/SignUp";
// Root layout component to wrap all routes
const RootLayout = (): JSX.Element => {
return (
<div className="flex-col min-h-screen">
<Navigation />
<main className="max-w-2xl mx-auto p-6 w-full">
<Outlet />
</main>
<footer>
<p
className="text-sm text-center"
style={{ color: "var(--text-muted)" }}
>
© {new Date().getFullYear()} Nhost Demo
</p>
</footer>
</div>
);
};
// Create router with routes
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route path="signin" element={<SignIn />} />
<Route path="signup" element={<SignUp />} />
<Route element={<ProtectedRoute />}>
<Route path="home" element={<Home />} />
<Route path="profile" element={<Profile />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Route>,
),
);
const App = (): JSX.Element => {
return <RouterProvider router={router} />;
};
export default App;

View File

@@ -0,0 +1,85 @@
import type { JSX } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Navigation(): JSX.Element {
const { isAuthenticated, nhost, session } = useAuth();
const location = useLocation();
// Helper function to determine if a link is active
const isActive = (path: string): string => {
return location.pathname === path ? "active" : "";
};
return (
<nav className="navbar">
<div className="navbar-container">
<div className="flex items-center">
<span className="navbar-brand">Nhost Demo</span>
<div className="navbar-links">
{isAuthenticated ? (
<>
<Link to="/home" className={`nav-link ${isActive("/home")}`}>
Home
</Link>
<Link
to="/profile"
className={`nav-link ${isActive("/profile")}`}
>
Profile
</Link>
</>
) : (
<>
<Link
to="/signin"
className={`nav-link ${isActive("/signin")}`}
>
Sign In
</Link>
<Link
to="/signup"
className={`nav-link ${isActive("/signup")}`}
>
Sign Up
</Link>
</>
)}
</div>
</div>
{isAuthenticated && (
<div>
<button
type="button"
onClick={async () => {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
}}
className="icon-button"
title="Sign Out"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Sign Out"
role="img"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
)}
</div>
</nav>
);
}

View File

@@ -0,0 +1,26 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
interface ProtectedRouteProps {
redirectTo?: string;
}
export default function ProtectedRoute({
redirectTo = "/signin",
}: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="loading-container">
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to={redirectTo} />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,552 @@
/* Base styles */
:root {
--background: #030712;
--foreground: #ffffff;
--card-bg: #111827;
--card-border: #1f2937;
--primary: #6366f1;
--primary-hover: #4f46e5;
--secondary: #10b981;
--secondary-hover: #059669;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--success: #22c55e;
--error: #ef4444;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-color: rgba(31, 41, 55, 0.7);
--font-geist-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
min-height: 100vh;
}
/* Layout */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.min-h-screen {
min-height: 100vh;
}
.w-full {
width: 100%;
}
.max-w-2xl {
max-width: 42rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.p-6 {
padding: 1.5rem;
}
.p-8 {
padding: 2rem;
}
.py-5 {
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
.mr-8 {
margin-right: 2rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.space-y-5 > * + * {
margin-top: 1.25rem;
}
.space-x-4 > * + * {
margin-left: 1rem;
}
/* Typography */
h1,
h2,
h3 {
font-weight: bold;
line-height: 1.2;
}
.text-3xl {
font-size: 1.875rem;
}
.text-2xl {
font-size: 1.5rem;
}
.text-xl {
font-size: 1.25rem;
}
.text-lg {
font-size: 1.125rem;
}
.text-sm {
font-size: 0.875rem;
}
.text-xs {
font-size: 0.75rem;
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
.font-medium {
font-weight: 500;
}
.text-center {
text-align: center;
}
.gradient-text {
background: linear-gradient(to right, var(--primary), var(--accent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Components */
.glass-card {
background: rgba(17, 24, 39, 0.7);
backdrop-filter: blur(8px);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
}
.btn {
display: inline-block;
padding: 0.625rem 1rem;
font-weight: 500;
border-radius: 0.375rem;
transition: all 0.2s ease;
cursor: pointer;
text-align: center;
border: none;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background-color: var(--secondary);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--secondary-hover);
}
.nav-link {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s ease;
color: var(--text-secondary);
text-decoration: none;
}
.nav-link:hover {
color: white;
background-color: rgba(31, 41, 55, 0.7);
}
.nav-link.active {
background-color: var(--primary);
color: white;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
input,
textarea,
select {
width: 100%;
padding: 0.625rem 0.75rem;
background-color: rgba(31, 41, 55, 0.8);
border: 1px solid var(--border-color);
color: white;
border-radius: 0.375rem;
transition: all 0.2s;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.alert {
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
}
.alert-error {
background-color: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
color: white;
}
.alert-success {
background-color: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.5);
color: white;
}
/* Navigation */
.navbar {
position: sticky;
top: 0;
z-index: 10;
background-color: rgba(17, 24, 39, 0.8);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
margin-bottom: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.navbar-container {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 42rem;
margin: 0 auto;
padding: 0 1.5rem;
}
.navbar-brand {
color: var(--primary);
font-weight: bold;
font-size: 1.125rem;
margin-right: 2rem;
}
.navbar-links {
display: flex;
gap: 1rem;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.875rem;
}
tr:hover {
background-color: rgba(31, 41, 55, 0.3);
}
/* File upload styles */
.file-upload {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
border: 2px dashed rgba(99, 102, 241, 0.3);
border-radius: 0.5rem;
background-color: rgba(31, 41, 55, 0.3);
cursor: pointer;
transition: all 0.2s;
}
.file-upload:hover {
border-color: var(--primary);
}
/* Footer */
footer {
padding: 1.25rem 0;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: center;
align-items: center;
}
/* Link styles */
a {
color: var(--primary);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
/* Loading state */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
}
/* Code blocks */
pre {
background-color: rgba(31, 41, 55, 0.8);
padding: 1rem;
border-radius: 0.375rem;
overflow: auto;
font-family: monospace;
border: 1px solid var(--border-color);
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Profile data */
.profile-item {
padding-bottom: 0.75rem;
margin-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.profile-item strong {
color: var(--text-secondary);
}
.action-link {
color: var(--primary);
font-weight: 500;
margin-right: 0.75rem;
cursor: pointer;
}
.action-link:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.action-link-danger {
color: var(--error);
}
.action-link-danger:hover {
color: #f05252;
}
/* Icon button */
.icon-button {
background-color: transparent;
color: var(--primary);
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.icon-button:hover {
background-color: rgba(99, 102, 241, 0.1);
color: var(--primary-hover);
}
.icon-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.icon-button svg {
width: 20px;
height: 20px;
}
/* Table action icons */
.table-actions {
display: flex;
gap: 8px;
}
.action-icon {
background-color: transparent;
border: none;
width: 32px;
height: 32px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
padding: 0;
}
.action-icon svg {
width: 18px;
height: 18px;
}
.action-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-icon-view {
color: var(--primary);
}
.action-icon-view:hover:not(:disabled) {
background-color: rgba(99, 102, 241, 0.1);
color: var(--primary-hover);
}
.action-icon-delete {
color: var(--error);
}
.action-icon-delete:hover:not(:disabled) {
background-color: rgba(239, 68, 68, 0.1);
color: #f05252;
}
/* Tab styles */
.tabs-container {
display: flex;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
}
.tab-button {
flex: 1;
padding: 0.75rem 1rem;
font-weight: 500;
transition: all 0.2s ease;
background-color: rgba(31, 41, 55, 0.5);
color: var(--text-secondary);
}
.tab-button:hover:not(.tab-active) {
background-color: rgba(31, 41, 55, 0.8);
color: var(--text-primary);
}
.tab-button.tab-active {
background-color: var(--primary);
color: white;
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.3);
}
.tab-button:first-child {
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
.tab-button:last-child {
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.tab-content {
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,77 @@
import { authExchange } from "@urql/exchange-auth";
import type { ReactNode } from "react";
import {
type Client,
cacheExchange,
createClient,
fetchExchange,
Provider,
} from "urql";
import { useAuth } from "../nhost/AuthProvider";
interface UrqlProviderProps {
children: ReactNode;
}
/**
* UrqlProvider component that provides the urql client to the React application.
*
* This component handles:
* - Creating the urql client with authentication support
* - Managing GraphQL requests with proper authentication headers
* - Token refresh handling through Nhost
*/
export const UrqlProvider = ({ children }: UrqlProviderProps) => {
const { nhost } = useAuth();
const client: Client = createClient({
url:
import.meta.env["VITE_NHOST_GRAPHQL_URL"] ||
"https://local.graphql.local.nhost.run/v1",
// Force POST requests (Hasura interprets GET requests as persisted queries)
preferGetMethod: false,
exchanges: [
cacheExchange,
authExchange(async (utils) => {
return {
addAuthToOperation(operation) {
const session = nhost.getUserSession();
if (!session?.accessToken) {
return operation;
}
return utils.appendHeaders(operation, {
Authorization: `Bearer ${session.accessToken}`,
});
},
didAuthError(error) {
return error.graphQLErrors.some((e) =>
e.message.includes("JWTExpired"),
);
},
async refreshAuth() {
const currentSession = nhost.getUserSession();
if (!currentSession?.refreshToken) {
return;
}
try {
await nhost.refreshSession(60);
} catch (e: unknown) {
console.error(
"Error refreshing session:",
e instanceof Error ? e : "Unknown error",
);
await nhost.auth.signOut({
refreshToken: currentSession.refreshToken,
});
}
},
};
}),
fetchExchange,
],
});
return <Provider value={client}>{children}</Provider>;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
query GetNinjaTurtlesWithComments {
ninjaTurtles {
id
name
description
createdAt
updatedAt
comments {
id
comment
createdAt
user {
id
displayName
email
}
}
}
}
mutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {
insertComment(object: { ninjaTurtleId: $ninjaTurtleId, comment: $comment }) {
id
comment
createdAt
ninjaTurtleId
}
}

View File

@@ -0,0 +1,174 @@
import { createClient, type NhostClient } from "@nhost/nhost-js";
import type { Session } from "@nhost/nhost-js/auth";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
/**
* Authentication context interface providing access to user session state and Nhost client.
* Used throughout the React application to access authentication-related data and operations.
*/
interface AuthContextType {
/** Current authenticated user object, null if not authenticated */
user: Session["user"] | null;
/** Current session object containing tokens and user data, null if no active session */
session: Session | null;
/** Boolean indicating if user is currently authenticated */
isAuthenticated: boolean;
/** Boolean indicating if authentication state is still loading */
isLoading: boolean;
/** Nhost client instance for making authenticated requests */
nhost: NhostClient;
}
// Create React context for authentication state and nhost client
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthProviderProps {
children: ReactNode;
}
/**
* AuthProvider component that provides authentication context to the React application.
*
* This component handles:
* - Initializing the Nhost client with default EventEmitterStorage
* - Managing authentication state (user, session, loading, authenticated status)
* - Cross-tab session synchronization using sessionStorage.onChange events
* - Page visibility and focus event handling to maintain session consistency
* - Client-side only session management (no server-side rendering)
*/
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<Session["user"] | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const lastRefreshTokenIdRef = useRef<string | null>(null);
// Initialize Nhost client with default SessionStorage (local storage)
const nhost = useMemo(
() =>
createClient({
region: import.meta.env.VITE_NHOST_REGION || "local",
subdomain: import.meta.env.VITE_NHOST_SUBDOMAIN || "local",
}),
[],
);
/**
* Handles session reload when refresh token changes.
* This detects when the session has been updated from other tabs.
*
* @param currentRefreshTokenId - The current refresh token ID to compare against stored value
*/
const reloadSession = useCallback(
(currentRefreshTokenId: string | null) => {
if (currentRefreshTokenId !== lastRefreshTokenIdRef.current) {
lastRefreshTokenIdRef.current = currentRefreshTokenId;
// Update local authentication state to match current session
const currentSession = nhost.getUserSession();
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
}
},
[nhost],
);
// Initialize authentication state and set up cross-tab session synchronization
useEffect(() => {
setIsLoading(true);
// Load initial session state from Nhost client
const currentSession = nhost.getUserSession();
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
lastRefreshTokenIdRef.current = currentSession?.refreshTokenId ?? null;
setIsLoading(false);
// Subscribe to session changes from other browser tabs
// This enables real-time synchronization when user signs in/out in another tab
const unsubscribe = nhost.sessionStorage.onChange((session) => {
reloadSession(session?.refreshTokenId ?? null);
});
return unsubscribe;
}, [nhost, reloadSession]);
// Handle session changes from page focus events (for additional session consistency)
useEffect(() => {
/**
* Checks for session changes when page becomes visible or focused.
* In the React SPA context, this provides additional consistency checks
* though it's less critical than in the Next.js SSR version.
*/
const checkSessionOnFocus = () => {
reloadSession(nhost.getUserSession()?.refreshTokenId ?? null);
};
// Monitor page visibility changes (tab switching, window minimizing)
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
checkSessionOnFocus();
}
});
// Monitor window focus events (clicking back into the browser window)
window.addEventListener("focus", checkSessionOnFocus);
// Cleanup event listeners on component unmount
return () => {
document.removeEventListener("visibilitychange", checkSessionOnFocus);
window.removeEventListener("focus", checkSessionOnFocus);
};
}, [nhost, reloadSession]);
const value: AuthContextType = {
user,
session,
isAuthenticated,
isLoading,
nhost,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
/**
* Custom hook to access the authentication context.
*
* Must be used within a component wrapped by AuthProvider.
* Provides access to current user session, authentication state, and Nhost client.
*
* @throws {Error} When used outside of AuthProvider
* @returns {AuthContextType} Authentication context containing user, session, and client
*
* @example
* ```tsx
* function MyComponent() {
* const { user, isAuthenticated, nhost } = useAuth();
*
* if (!isAuthenticated) {
* return <div>Please sign in</div>;
* }
*
* return <div>Welcome, {user?.displayName}!</div>;
* }
* ```
*/
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

View File

@@ -0,0 +1,22 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { UrqlProvider } from "./lib/graphql/UrqlProvider";
import { AuthProvider } from "./lib/nhost/AuthProvider";
// Root component that sets up providers
const Root = () => (
<React.StrictMode>
<AuthProvider>
<UrqlProvider>
<App />
</UrqlProvider>
</AuthProvider>
</React.StrictMode>
);
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(<Root />);

View File

@@ -0,0 +1,217 @@
/* Custom styles for Ninja Turtles tabs interface */
.ninja-turtles-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.ninja-turtles-title {
text-align: center;
margin-bottom: 25px;
color: #1a9c44;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Tab navigation */
.turtle-tabs {
display: flex;
border-bottom: 2px solid #1a9c44;
margin-bottom: 20px;
overflow-x: auto;
}
.turtle-tab {
padding: 10px 20px;
margin-right: 5px;
background: none;
border: none;
cursor: pointer;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.3s ease;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
position: relative;
outline: none;
}
.turtle-tab:hover {
color: var(--text-primary);
background: rgba(26, 156, 68, 0.1);
}
.turtle-tab.active {
color: white;
background: #1a9c44;
}
/* Turtle Card Styles */
.turtle-card {
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
animation: fadeIn 0.5s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.turtle-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.turtle-name {
color: #1a9c44;
font-weight: 700;
}
.turtle-description {
margin-bottom: 20px;
line-height: 1.6;
}
.turtle-date {
font-size: 0.85rem;
margin-bottom: 20px;
color: var(--text-muted);
}
/* Comments section */
.comments-section {
margin-top: 25px;
border-top: 1px solid var(--border-color);
padding-top: 15px;
}
.comments-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 15px;
color: #1a9c44;
}
.comment-card {
margin-bottom: 15px;
padding: 12px;
border-radius: 6px;
background-color: rgba(26, 156, 68, 0.05);
border: 1px solid rgba(26, 156, 68, 0.1);
}
.comment-text {
margin-bottom: 8px;
}
.comment-meta {
display: flex;
align-items: center;
font-size: 0.8rem;
color: var(--text-muted);
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #1a9c44;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
margin-right: 8px;
}
/* Comment form */
.comment-form {
margin-top: 20px;
}
.comment-textarea {
width: 100%;
background-color: rgba(31, 41, 55, 0.8);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
color: white;
transition: all 0.2s;
margin-bottom: 10px;
}
.comment-textarea:focus {
border-color: #1a9c44;
box-shadow: 0 0 0 2px rgba(26, 156, 68, 0.2);
outline: none;
}
.comment-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cancel-button {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.cancel-button:hover {
background-color: rgba(31, 41, 55, 0.8);
}
.submit-button {
background-color: #1a9c44;
color: white;
}
.submit-button:hover {
background-color: #148035;
}
.add-comment-button {
display: inline-flex;
align-items: center;
color: #1a9c44;
background: none;
border: none;
padding: 5px 0;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.add-comment-button:hover {
color: #148035;
text-decoration: underline;
}
.add-comment-button svg {
width: 14px;
height: 14px;
margin-right: 5px;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.turtle-tabs {
flex-wrap: nowrap;
overflow-x: auto;
}
.turtle-tab {
flex: 0 0 auto;
}
}

View File

@@ -0,0 +1,202 @@
import { type JSX, useState } from "react";
import { useMutation, useQuery } from "urql";
import {
AddCommentDocument,
GetNinjaTurtlesWithCommentsDocument,
} from "../lib/graphql/__generated__/graphql";
import { useAuth } from "../lib/nhost/AuthProvider";
import "./Home.css";
export default function Home(): JSX.Element {
const { isLoading } = useAuth();
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
const [commentText, setCommentText] = useState("");
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [{ data, fetching: loading, error }] = useQuery({
query: GetNinjaTurtlesWithCommentsDocument,
});
// Log the full error for debugging
if (error) {
console.error("GraphQL Error:", error);
console.error("GraphQL Error details:", JSON.stringify(error, null, 2));
}
const [, addComment] = useMutation(AddCommentDocument);
// If authentication is still loading, show a loading state
if (isLoading) {
return (
<div className="loading-container">
<p>Loading...</p>
</div>
);
}
const handleAddComment = async (turtleId: string) => {
if (!commentText.trim()) return;
const result = await addComment({
ninjaTurtleId: turtleId,
comment: commentText,
});
if (!result.error) {
setCommentText("");
setActiveCommentId(null);
}
};
if (loading)
return (
<div className="loading-container">
<p>Loading ninja turtles...</p>
</div>
);
if (error)
return (
<div className="alert alert-error">
Error loading ninja turtles: {(error as Error).message}
</div>
);
// Access the data using the correct field name from the GraphQL response
const ninjaTurtles = data?.ninjaTurtles || [];
if (!ninjaTurtles || ninjaTurtles.length === 0) {
return (
<div className="no-turtles-container">
<p>No ninja turtles found. Please add some!</p>
</div>
);
}
// Set the active tab to the first turtle if there's no active tab and there are turtles
if (activeTabId === null) {
setActiveTabId(ninjaTurtles[0] ? ninjaTurtles[0].id : null);
}
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
};
return (
<div className="ninja-turtles-container">
<h1 className="ninja-turtles-title text-3xl font-bold mb-6">
Teenage Mutant Ninja Turtles
</h1>
{/* Tabs navigation */}
<div className="turtle-tabs">
{ninjaTurtles.map((turtle) => (
<button
key={turtle.id}
type="button"
className={`turtle-tab ${activeTabId === turtle.id ? "active" : ""}`}
onClick={() => setActiveTabId(turtle.id)}
>
{turtle.name}
</button>
))}
</div>
{/* Display active turtle */}
{ninjaTurtles
.filter((turtle) => turtle.id === activeTabId)
.map((turtle) => (
<div key={turtle.id} className="turtle-card glass-card p-6">
<div className="turtle-header">
<h2 className="turtle-name text-2xl font-semibold">
{turtle.name}
</h2>
</div>
<p className="turtle-description">{turtle.description}</p>
<div className="turtle-date">
Added on {formatDate(turtle.createdAt || turtle.createdAt)}
</div>
<div className="comments-section">
<h3 className="comments-title">
Comments ({turtle.comments.length})
</h3>
{turtle.comments.map((comment) => (
<div key={comment.id} className="comment-card">
<p className="comment-text">{comment.comment}</p>
<div className="comment-meta">
<div className="comment-avatar">
{(comment.user?.displayName || comment.user?.email || "?")
.charAt(0)
.toUpperCase()}
</div>
<p>
{comment.user?.displayName ||
comment.user?.email ||
"Anonymous"}{" "}
- {formatDate(comment.createdAt || comment.createdAt)}
</p>
</div>
</div>
))}
{activeCommentId === turtle.id ? (
<div className="comment-form">
<textarea
className="comment-textarea"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Add your comment..."
rows={3}
/>
<div className="comment-actions">
<button
type="button"
onClick={() => {
setActiveCommentId(null);
setCommentText("");
}}
className="btn cancel-button"
>
Cancel
</button>
<button
type="button"
onClick={() => handleAddComment(turtle.id)}
className="btn submit-button"
>
Submit
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setActiveCommentId(turtle.id)}
className="add-comment-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Add Comment"
role="img"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add a comment
</button>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import type { JSX } from "react";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Profile(): JSX.Element {
const { user, session } = useAuth();
// ProtectedRoute component now handles authentication check
// We can just focus on the component logic here
return (
<div className="flex flex-col">
<h1 className="text-3xl mb-6 gradient-text">Your Profile</h1>
<div className="glass-card p-8 mb-6">
<div className="space-y-5">
<div className="profile-item">
<strong>Display Name:</strong>
<span className="ml-2">{user?.displayName || "Not set"}</span>
</div>
<div className="profile-item">
<strong>Email:</strong>
<span className="ml-2">{user?.email || "Not available"}</span>
</div>
<div className="profile-item">
<strong>User ID:</strong>
<span
className="ml-2"
style={{
fontFamily: "var(--font-geist-mono)",
fontSize: "0.875rem",
}}
>
{user?.id || "Not available"}
</span>
</div>
<div className="profile-item">
<strong>Roles:</strong>
<span className="ml-2">{user?.roles?.join(", ") || "None"}</span>
</div>
<div className="profile-item">
<strong>Email Verified:</strong>
<span className="ml-2">{user?.emailVerified ? "Yes" : "No"}</span>
</div>
</div>
</div>
<div className="glass-card p-8 mb-6">
<h3 className="text-xl mb-4">Session Information</h3>
<pre>
{JSON.stringify(
{
refreshTokenId: session?.refreshTokenId,
accessTokenExpiresIn: session?.accessTokenExpiresIn,
},
null,
2,
)}
</pre>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import { type JSX, useEffect, useId, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function SignIn(): JSX.Element {
const { nhost, isAuthenticated } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const params = new URLSearchParams(location.search);
const emailId = useId();
const passwordId = useId();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(
params.get("error") || null,
);
const isVerifying = params.has("fromVerify");
// Use useEffect for navigation after authentication is confirmed
useEffect(() => {
if (isAuthenticated && !isVerifying) {
navigate("/home");
}
}, [isAuthenticated, isVerifying, navigate]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
// Use the signIn function from auth context
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
// Check if MFA is required
if (response.body?.mfa) {
navigate(`/signin/mfa?ticket=${response.body.mfa.ticket}`);
return;
}
// If we have a session, sign in was successful
if (response.body?.session) {
navigate("/home");
} else {
setError("Failed to sign in");
}
} catch (err) {
const error = err as FetchError<ErrorResponse>;
setError(`An error occurred during sign in: ${error.message}`);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
<div className="glass-card w-full p-8 mb-6">
<h2 className="text-2xl mb-6">Sign In</h2>
<div>
<div className="tabs-container">
<button type="button" className="tab-button tab-active">
Email + Password
</button>
</div>
<div className="tab-content">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor={passwordId}>Password</label>
<input
id={passwordId}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className="alert alert-error">{error}</div>}
<button
type="submit"
className="btn btn-primary w-full"
disabled={isLoading}
>
{isLoading ? "Signing In..." : "Sign In"}
</button>
</form>
</div>
</div>
</div>
<div className="mt-4">
<p>
Don&apos;t have an account? <Link to="/signup">Sign Up</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import { type JSX, useId, useState } from "react";
import { Link, Navigate, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function SignUp(): JSX.Element {
const { nhost, isAuthenticated } = useAuth();
const navigate = useNavigate();
const displayNameId = useId();
const emailId = useId();
const passwordId = useId();
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [displayName, setDisplayName] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// If already authenticated, redirect to profile
if (isAuthenticated) {
return <Navigate to="/home" />;
}
const handleSubmit = async (
e: React.FormEvent<HTMLFormElement>,
): Promise<void> => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
const response = await nhost.auth.signUpEmailPassword({
email,
password,
options: {
displayName,
},
});
if (response.body) {
// Successfully signed up and automatically signed in
navigate("/home");
} else {
// Verification email sent
navigate("/verify");
}
} catch (err) {
const error = err as FetchError<ErrorResponse>;
setError(`An error occurred during sign up: ${error.message}`);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
<div className="glass-card w-full p-8 mb-6">
<h2 className="text-2xl mb-6">Sign Up</h2>
<div>
<div className="tabs-container">
<button type="button" className="tab-button tab-active">
Email + Password
</button>
</div>
<div className="tab-content">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor={displayNameId}>Display Name</label>
<input
id={displayNameId}
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div>
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor={passwordId}>Password</label>
<input
id={passwordId}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<p className="text-xs mt-1 text-gray-400">
Password must be at least 8 characters long
</p>
</div>
{error && <div className="alert alert-error">{error}</div>}
<button
type="submit"
className="btn btn-primary w-full"
disabled={isLoading}
>
{isLoading ? "Signing Up..." : "Sign Up"}
</button>
</form>
</div>
</div>
</div>
<div className="mt-4">
<p>
Already have an account? <Link to="/signin">Sign In</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_NHOST_REGION: string | undefined;
readonly VITE_NHOST_SUBDOMAIN: string | undefined;
readonly VITE_ENV: string | undefined;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../build/configs/tsconfig/frontend.json",
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../build/configs/tsconfig/vite.json"
}

View File

@@ -0,0 +1,7 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
});

View File

@@ -1,22 +1,22 @@
{ final }:
let
version = "1.33.0";
version = "1.34.0";
dist = {
aarch64-darwin = {
url = "https://github.com/nhost/nhost/releases/download/cli%40${version}/cli-${version}-darwin-arm64.tar.gz";
sha256 = "0d4l4pmcz79147xyc1ag6zahl5jbmwl6a86cccnx13axbf0gxh2b";
sha256 = "1vcrx9wm3qfh1qm1kp3bqq7cnnbf69xgnxq52l6x7xdnam2nn0gp";
};
x86_64-darwin = {
url = "https://github.com/nhost/nhost/releases/download/cli%40${version}/cli-${version}-darwin-amd64.tar.gz";
sha256 = "16n1j1ml7p9m00mhs0wzxfj27x951xx70q6hp6j6m9s3m0y7wbgz";
sha256 = "0jc7slzixbs65h8n40d5w3kxqpg2js9a1ks83rz832rg0lxhkmam";
};
aarch64-linux = {
url = "https://github.com/nhost/nhost/releases/download/cli%40${version}/cli-${version}-linux-arm64.tar.gz";
sha256 = "1z0vi2yb932yk4y7v1xwwbxx4h582mk5pd0j2fv7nvw23rgxmcd7";
sha256 = "1wy6jb657yy07c2ijp4d0b9mh6yp9bb1lwmhsnrm2m4k8jhv3pa1";
};
x86_64-linux = {
url = "https://github.com/nhost/nhost/releases/download/cli%40${version}/cli-${version}-linux-amd64.tar.gz";
sha256 = "1q3pg5kdwdphdfpwzpnn41hdzdxy2l5l0vw23xwjqjand68cpyip";
sha256 = "1pk2k9lds9m6r9zxfpiddbfxw0a07hlbljml7xhnmb52nw8rblmd";
};
};

View File

@@ -1,104 +1,76 @@
final: prev: rec {
postgresql_14_18 = (prev.postgresql_14.override { systemdSupport = false; }).overrideAttrs (finalAttrs: previousAttrs: rec {
postgresql_16_10 = (prev.postgresql_16.override { systemdSupport = false; }).overrideAttrs (finalAttrs: previousAttrs: rec {
pname = "postgresql";
version = "14.18";
version = "16.10";
src = final.fetchurl {
url = "mirror://postgresql/source/v${version}/${pname}-${version}.tar.bz2";
hash = "sha256-g6sp1r/D3Fiy7TxmQRT9++tqBFDEuNf6aa7pHjyhT44=";
hash = "sha256-3oSF9M6cMuPd/u8LfCYe7RzstUybzRcOQ3/0VMspK0I=";
};
doCheck = false;
doInstallCheck = false;
});
postgresql_14_18-client = final.stdenv.mkDerivation {
postgresql_16_10-client = final.stdenv.mkDerivation {
pname = "postgresql-client";
version = postgresql_14_18.version;
version = postgresql_16_10.version;
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin
cp ${postgresql_14_18}/bin/psql $out/bin/
cp ${postgresql_14_18}/bin/pg_dump $out/bin/
cp ${postgresql_14_18}/bin/pg_dumpall $out/bin/
cp ${postgresql_14_18}/bin/pg_restore $out/bin/
cp ${postgresql_16_10}/bin/psql $out/bin/
cp ${postgresql_16_10}/bin/pg_dump $out/bin/
cp ${postgresql_16_10}/bin/pg_dumpall $out/bin/
cp ${postgresql_16_10}/bin/pg_restore $out/bin/
'';
};
postgresql_15_13 = (prev.postgresql_15.override { systemdSupport = false; }).overrideAttrs (finalAttrs: previousAttrs: rec {
postgresql_17_6 = (prev.postgresql_17.override { systemdSupport = false; }).overrideAttrs (finalAttrs: previousAttrs: rec {
pname = "postgresql";
version = "15.13";
version = "17.6";
src = final.fetchurl {
url = "mirror://postgresql/source/v${version}/${pname}-${version}.tar.bz2";
hash = "sha256-T2LhM9IuoIoEAbCECSDiZphkTQGoDDQ0H7cy3QqQyl0=";
hash = "sha256-4GMKNgCuonURcVVjJZ7CERzV9DU6SwQOC+gn+UzXqLA=";
};
});
postgresql_15_13-client = final.stdenv.mkDerivation {
postgresql_17_6-client = final.stdenv.mkDerivation {
pname = "postgresql-client";
version = postgresql_15_13.version;
version = postgresql_17_6.version;
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin
cp ${postgresql_15_13}/bin/psql $out/bin/
cp ${postgresql_15_13}/bin/pg_dump $out/bin/
cp ${postgresql_15_13}/bin/pg_dumpall $out/bin/
cp ${postgresql_15_13}/bin/pg_restore $out/bin/
cp ${postgresql_17_6}/bin/psql $out/bin/
cp ${postgresql_17_6}/bin/pg_dump $out/bin/
cp ${postgresql_17_6}/bin/pg_dumpall $out/bin/
cp ${postgresql_17_6}/bin/pg_restore $out/bin/
'';
};
postgresql_16_9 = (prev.postgresql_16.override { systemdSupport = false; }).overrideAttrs (finalAttrs: previousAttrs: rec {
postgresql_18_0 = (prev.postgresql_18.override { systemdSupport = false; }).overrideAttrs (finalAttrs: previousAttrs: rec {
pname = "postgresql";
version = "16.9";
version = "18.0";
src = final.fetchurl {
url = "mirror://postgresql/source/v${version}/${pname}-${version}.tar.bz2";
hash = "sha256-B8APuCTfCgwpXySfRGkbhuMmZ1OzgMlvYzwzEeEL0AU=";
hash = "sha256-DVuQOx5f42G8p6qVB1GZM3c+s0JmsTV8TneA/e5tYHg=";
};
});
postgresql_16_9-client = final.stdenv.mkDerivation {
postgresql_18_0-client = final.stdenv.mkDerivation {
pname = "postgresql-client";
version = postgresql_16_9.version;
version = postgresql_18_0.version;
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin
cp ${postgresql_16_9}/bin/psql $out/bin/
cp ${postgresql_16_9}/bin/pg_dump $out/bin/
cp ${postgresql_16_9}/bin/pg_dumpall $out/bin/
cp ${postgresql_16_9}/bin/pg_restore $out/bin/
'';
};
postgresql_17_5 = (prev.postgresql_17.override { systemdSupport = false; }).overrideAttrs (finalAttrs: previousAttrs: rec {
pname = "postgresql";
version = "17.5";
src = final.fetchurl {
url = "mirror://postgresql/source/v${version}/${pname}-${version}.tar.bz2";
hash = "sha256-/LerOOI7Jk0ZAssl5q2vtFJabry9AVQ0ru+e2oD1KNg=";
};
});
postgresql_17_5-client = final.stdenv.mkDerivation {
pname = "postgresql-client";
version = postgresql_17_5.version;
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin
cp ${postgresql_17_5}/bin/psql $out/bin/
cp ${postgresql_17_5}/bin/pg_dump $out/bin/
cp ${postgresql_17_5}/bin/pg_dumpall $out/bin/
cp ${postgresql_17_5}/bin/pg_restore $out/bin/
cp ${postgresql_17_6}/bin/psql $out/bin/
cp ${postgresql_17_6}/bin/pg_dump $out/bin/
cp ${postgresql_17_6}/bin/pg_dumpall $out/bin/
cp ${postgresql_17_6}/bin/pg_restore $out/bin/
'';
};
}

View File

@@ -35,14 +35,12 @@ let
oapi-codegen
nhost-cli
skopeo
postgresql_14_18-client
postgresql_15_13-client
postgresql_16_9-client
postgresql_17_5-client
postgresql_14_18
postgresql_15_13
postgresql_16_9
postgresql_17_5
postgresql_16_10-client
postgresql_17_6-client
postgresql_18_0-client
postgresql_16_10
postgresql_17_6
postgresql_18_0
];
nativeBuildInputs = [ ];