GraphJin Configuration Reference

July 20, 2026 · View on GitHub

This document provides a comprehensive reference for all GraphJin configuration options. GraphJin uses YAML or JSON configuration files that can be customized for different environments.

How configuration works

This file is the exhaustive field reference. If you want the mental model first — what the config file is, how values are resolved across layers, and every way to change it without memorizing keys — read How Configuration Works. In short:

  • One file, two halves. The file configures both the query engine (sources, tables, roles, …) and the server around it (auth, rate_limiter, redis, caching, agent, …). Engine changes reload in place; most server changes need a restart.
  • Layered overrides (later wins): built-in defaults → inherited parent (inherits:) → your file → GJ_*/SJ_* environment variables → dev-mode auto-enables.
  • Many ways to change it, not just editing YAML:
    • Editor autocomplete via a # yaml-language-server: $schema=./config.schema.json modeline (scaffolded automatically).
    • The graphjin config CLI — get, set, unset, explain, validate, schema, docs — offline and comment-preserving.
    • The built-in agent (natural-language config recipes) and, in dev, the get_current_config / validate_config / update_current_config MCP tools.
    • The gj_config GraphQL control plane (reflects both halves, secrets redacted).

Run graphjin config docs (or graphjin serve new) for the annotated example templates that document every option inline.

Table of Contents


Introduction

Config File Formats

GraphJin supports both YAML and JSON configuration files:

  • config/dev.yml - Development configuration
  • config/prod.yml - Production configuration
  • config/agentic.yml - Agentic deployment configuration
  • config/dev.json - JSON format alternative

Environment-Based Config Selection

GraphJin automatically selects the configuration file based on the GO_ENV environment variable:

GO_ENV ValueConfig File
development, dev, or emptydev.yml
production, prodprod.yml
agentic, agentagentic.yml
staging, stagestage.yml
testing, testtest.yml
Other values{value}.yml

The selected config name also supplies the default mode: dev.yml defaults to mode: dev, prod.yml defaults to mode: prod, and agentic.yml defaults to mode: agentic. agentic.yml is required for GO_ENV=agentic; use inherits: prod inside agentic.yml when an agentic deployment should reuse production settings.

Config Inheritance

Use the inherits field to inherit configuration from another file, allowing you to override only specific values:

# prod.yml - inherits all settings from dev.yml and overrides specific ones
inherits: dev

mode: prod
log_level: "warn"
web_ui: false

Environment Variable Overrides

Configuration values can be overridden using environment variables with the GJ_ or SJ_ prefix:

# Override database host
export GJ_DATABASE_HOST=mydb.example.com

# Override database port
export GJ_DATABASE_PORT=5433

What Changed: Database vs Sources

Use the legacy database: / databases: shape only for database-only configs that do not define sources:. Once sources: is present, every graph provider is declared as a source and tables point at tables[].source instead of tables[].database.

Old / legacy database-only config:

databases:
  app:
    type: postgres
    connection_string: ${APP_DATABASE_URL}

  analytics:
    type: snowflake
    connection_string: ${ANALYTICS_DSN}

tables:
  - name: users
    database: app

  - name: events
    database: analytics

New / sources config, shown as agentic.yml:

sources:
  - name: app
    kind: database
    type: postgres
    connection_string: ${APP_DATABASE_URL}
    default: true

  - name: analytics
    kind: database
    type: snowflake
    connection_string: ${ANALYTICS_DSN}

tables:
  - name: users
    source: app

  - name: events
    source: analytics

Canonical source kinds are database, code, file, and api. System roots and workflows are built-in features, not sources. Old implementation names such as sql, codesql, filesystem, openapi, graphjin, workflow, and workflows are rejected with errors that name the replacement.

Use mode: agentic or GO_ENV=agentic for deployments where authenticated company users interact with GraphJin through an approved agent. Agentic mode applies production-oriented source and control-plane defaults, enables safe discovery and approved workflow execution by default, and keeps detailed security/config/workflow-code surfaces blocked unless explicitly enabled with feature capabilities. Existing production: true configs still work and imply mode: prod when mode is omitted.


Quick Start

Minimal Development Configuration

app_name: "My App Development"
host_port: 0.0.0.0:8080
web_ui: true
production: false

database:
  type: postgres
  host: localhost
  port: 5432
  dbname: myapp_development
  user: postgres
  password: ""

Minimal Production Configuration

inherits: dev

app_name: "My App Production"
mode: prod
web_ui: false
log_level: "warn"
log_format: "json"
auth_fail_block: true

database:
  dbname: myapp_production

MCP Client Connection Quick Reference

For local development, the GraphJin helper is the easiest way to add GraphJin to Codex or Claude. It normalizes the URL to /api/v1/mcp, probes the server, and uses the right native URL or local proxy config:

graphjin mcp add codex
graphjin mcp add claude

Native MCP clients can also connect directly to GraphJin's Streamable HTTP endpoint:

codex mcp add graphjin --url http://localhost:8080/api/v1/mcp
claude mcp add --transport http graphjin http://localhost:8080/api/v1/mcp

Hosted GraphJin should use HTTPS and can advertise MCP OAuth metadata when mcp.oauth.enabled: true:

codex mcp add graphjin --url https://graphjin.example.com/api/v1/mcp
claude mcp add --transport http graphjin https://graphjin.example.com/api/v1/mcp
auth:
  type: jwt
  jwt:
    secret: ${GRAPHJIN_JWT_SECRET}

auth_login:
  enabled: true
  oidc:
    issuer_url: https://accounts.google.com
    client_id: ${GRAPHJIN_OIDC_CLIENT_ID}
    client_secret: ${GRAPHJIN_OIDC_CLIENT_SECRET}

mcp:
  oauth:
    enabled: true
    mode: builtin
    scopes: ["mcp"]

Claude's SSE transport is for legacy/custom MCP servers, not GraphJin's /api/v1/mcp endpoint:

claude mcp add --transport sse <name> <url>
claude mcp add --transport sse private-api https://api.company.com/sse \
  --header "X-API-Key: your-key-here"

Sources Mode

sources: is the canonical config for external providers only: databases, code indexes, file/object-store tables, and OpenAPI-backed APIs.

If sources: is absent, GraphJin runs in legacy database-only mode. Existing database, databases, and tables[].database SQL configs continue to work there. Legacy mode rejects CodeSQL, top-level filesystems, and top-level openapi / openapi_specs_dir; move external providers to sources. Replace old metadata/catalog overrides with system.capabilities.catalog.read.

If sources: is present, source mode is active. In source mode:

  • Configure databases and code indexes through sources; do not use legacy database or databases.
  • Every tables[] entry must set source, not database.
  • System roots and workflows use mode defaults and require no source entries.
  • Use optional top-level system: and workflows: sections only to override those defaults.
  • Use sources[].capabilities to enable or block source surfaces; valid capability keys depend on the source kind.
  • tables[].read_only blocks normal and managed mutations for that root.
  • relationships: [{ from, to, as? }] adds graph edges; cardinality and reverse traversal are inferred.
  • Legacy MCP discovery/action tools and legacy HTTP helper endpoints (/api/v1/discovery*, /api/v1/workflows/*) are disabled by default; set mcp.legacy_discovery: true as an escape hatch.

Direct migration:

# Old (rejected)
sources:
  - name: graphjin
    kind: graphjin
    capabilities:
      catalog.read: true
      config.write: false
    access:
      roots:
        gj_catalog: public
        gj_config: admin
  - name: workflows
    kind: workflow
    path: ./workflows
    runtime: javascript
    capabilities:
      workflow.execute: true

# New
system:
  capabilities:
    catalog.read: true
    config.write: false
  root_access:
    gj_catalog: public
    gj_config: admin
workflows:
  path: ./workflows
  capabilities:
    execute: true

Names are not reserved. An external application source may be named graphjin, workflows, or __graphjin_artifacts as long as its kind is one of the four external kinds.

sources:
  - name: app
    kind: database
    type: postgres
    connection_string: ${APP_DATABASE_URL}
    default: true

  - name: code
    kind: code
    path: /srv/app
    infer_db_refs: true

  - name: avatars
    kind: file
    backend: s3
    bucket: app-assets
    prefix: avatars/

  - name: upstream
    kind: api
    specs_dir: ./config/specs
    specs:
      interaction_studio:
        base_url: https://${IS_ACCOUNT}.example.com/api
        auth: {}

tables:
  - name: users
    source: app

  - name: gj_code
    source: code
    read_only: true

  - name: avatars
    source: avatars

relationships:
  - from: gj_catalog.code_refs_id
    to: code:gj_code.db_object_id
    as: gj_code

system:
  root_access:
    gj_catalog: public
    gj_config: admin

workflows:
  path: ./workflows

Source Capabilities

Each external source can define a capabilities map. A value of true enables the capability for authenticated default users, false disables it, and an omitted key uses the secure default for the active mode. Anonymous access is never granted by source capabilities. read_only: true still hard-blocks writes, deletes, and watches where applicable.

Deployment modes:

ModeIntended audienceDefault posture
devDevelopersBroad catalog, config, security, workflow-code, raw GraphQL, schema, and dev-tool access for local development
prodProduction applicationsProduction protections and allow-list discipline; the agentic system surface does not mount
agenticAuthenticated company end users using an approved agentProduction protections plus catalog.read and approved workflow.execute; detailed security.read, config.read, workflow.read, writes, raw GraphQL, schema writes, dev tools, and legacy discovery stay blocked unless explicitly enabled
Source kindCapability keys
databasedata.read, data.write, schema.read, schema.write
codecode.search, code.read, code.write, code.watch, code.infer_db_refs
filefiles.list, files.read, files.write, files.delete, files.watch
apiapi.read, api.write
System capabilities live under system.capabilities: catalog.read, security.read, config.read, config.write, runtime.read, raw_graphql.query, raw_graphql.mutate, schema.reload, schema.write, dev_tools.read, and legacy_discovery.read.

Workflow capabilities live under workflows.capabilities: execute, read, and write.

system:
  capabilities:
    catalog.read: true
    security.read: true
    config.read: false

workflows:
  path: ./workflows
  capabilities:
    execute: true
    read: false
    write: false

Service Configuration

OptionTypeDefaultDescription
app_namestring-Application name used in logs and debug messages
host_portstring0.0.0.0:8080Host and port the service runs on
hoststring-Host to run the service on (alternative to host_port)
portstring-Port to run the service on (alternative to host_port)
productionbooleanfalseLegacy production switch; prefer mode: prod or mode: agentic in new configs
web_uibooleanfalseEnable the GraphJin web UI
log_levelstringinfoLogging level: debug, error, warn, info
log_formatstringautoLog format: auto, json, simple
http_compressbooleantrueEnable HTTP gzip compression
server_timingbooleantrueEnable Server-Timing HTTP header
enable_tracingbooleanfalseEnable OpenTrace request tracing
auth_fail_blockbooleanfalseReturn HTTP 401 on auth failure
reload_on_config_changeboolean-Reload service on config file changes
cors_allowed_origins[]string-CORS allowed origins (use ["*"] for all)
cors_allowed_headers[]string-CORS allowed headers
cors_debugbooleanfalseEnable CORS debug logging
cache_controlstring-HTTP Cache-Control header value

Log Format Behavior

log_formatDevelopment ModeProduction Mode
autoColored consoleJSON
jsonJSONJSON
simpleColored consoleColored console

Example

app_name: "My GraphQL API"
host_port: 0.0.0.0:8080
production: false
web_ui: true
log_level: "debug"
log_format: "auto"
http_compress: true
server_timing: true
enable_tracing: true
auth_fail_block: false
reload_on_config_change: true

cors_allowed_origins: ["https://myapp.com", "https://*.myapp.com"]
cors_allowed_headers: ["Authorization", "Content-Type"]
cors_debug: false

cache_control: "public, max-age=300, s-maxage=600"

Database Configuration

Connection Options

OptionTypeDefaultDescription
typestringpostgresDatabase type
connection_stringstring-Full connection string (alternative to individual params)
hoststringlocalhostDatabase host
portinteger5432Database port
dbnamestring-Database name
userstringpostgresDatabase user
passwordstring-Database password
schemastringpublicDatabase schema (PostgreSQL)

Connection Pool Options

OptionTypeDefaultDescription
pool_sizeinteger10Size of the connection pool
max_connectionsinteger-Maximum number of active connections
max_connection_idle_timeduration-Max idle time before closing connection
max_connection_life_timeduration-Max lifetime of a connection
ping_timeoutduration-Timeout for health check pings

TLS Options

OptionTypeDefaultDescription
enable_tlsbooleanfalseEnable TLS encrypted connection
server_namestring-TLS server name (e.g., GCP project:instance)
server_certstring-Server certificate (file path or PEM content)
client_certstring-Client certificate (file path or PEM content)
client_keystring-Client key (file path or PEM content)

Supported Database Types

TypeSingle DBMulti-DBNotes
postgresYesYesDefault database type
mysqlYesYesUse with MariaDB as well
mariadbYesYesAlias for MySQL driver
sqliteYesYesSet host to file path
oracleYesYesOracle Database
mssqlNoYesMicrosoft SQL Server
mongodbNoYesMongoDB (multi-db only)
snowflakeYesYesRequires connection_string
clickhouseYesYesColumnar OLAP, reads-first. No foreign keys (declare relationships in config), async/best-effort mutations, single-table writes only

CodeSQL is not a database.type; configure repository/code indexes as sources[].kind: code.

Database Configuration Examples

PostgreSQL

database:
  type: postgres
  host: localhost
  port: 5432
  dbname: myapp
  user: postgres
  password: secret
  schema: "public"
  pool_size: 15

  # Or use connection string
  # connection_string: postgres://user:password@localhost:5432/myapp?sslmode=disable

MySQL / MariaDB

database:
  type: mysql
  host: localhost
  port: 3306
  dbname: myapp
  user: root
  password: secret

  # Or use connection string (recommended params included)
  # connection_string: user:password@tcp(localhost:3306)/myapp?multiStatements=true&parseTime=true&interpolateParams=true

SQLite

database:
  type: sqlite
  # File-based database
  host: /path/to/database.db

  # Or in-memory with shared cache
  # connection_string: file:memdb?mode=memory&cache=shared&_busy_timeout=5000

Code Source (CodeSQL)

Code indexes are configured as sources[].kind: code and backed by CodeSQL internally. Set path; GraphJin creates a managed SQLite cache in config/codesql/<source-name>-<source-root-hash>.sqlite and reconciles new/changed/deleted files on startup. In development, it also watches the source tree while running; in production, live watching is disabled and the cache updates on restart.

sources:
  - name: code
    kind: code
    path: /path/to/source

For mixed application-data and code graphs, declare both sources and attach tables to sources:

sources:
  - name: app
    kind: database
    type: postgres
    connection_string: postgres://app:secret@db/app
    default: true

  - name: code
    kind: code
    path: /path/to/source
    infer_db_refs: true

tables:
  - name: users
    source: app

  - name: gj_code
    source: code
    read_only: true

At runtime GraphJin treats CodeSQL as sqlite with read_only: true and analytics_mode: true. The public graph exposes one root, gj_code, where kind selects files, symbols, references, imports, edges, database references, injections, docs, text chunks, parse errors, AST nodes, captures, index status, change sets, and locks. Raw CodeSQL storage tables stay internal by default.

infer_db_refs is CodeSQL-only and defaults to true. It records best-effort references from SQL files and strings, GraphQL files and strings, GraphJin config/allowlist files, migration references, and common struct/model tags.

Oracle

database:
  type: oracle
  host: localhost
  port: 1521
  dbname: FREEPDB1
  user: myuser
  password: secret

  # Or use connection string
  # connection_string: oracle://user:password@localhost:1521/FREEPDB1

MS SQL Server

database:
  type: mssql
  host: localhost
  port: 1433
  dbname: myapp
  user: sa
  password: YourStrong!Passw0rd

  # Or use connection string
  # connection_string: sqlserver://sa:YourStrong!Passw0rd@localhost:1433?database=myapp

MongoDB

database:
  type: mongodb
  host: localhost
  port: 27017
  dbname: myapp
  # Note: MongoDB has no foreign keys; relationships must be configured explicitly

Snowflake

database:
  type: snowflake
  # Snowflake requires a connection string (host/port fields are not used)
  connection_string: user@myaccount/mydb/public?warehouse=compute_wh&account=myaccount
Key Pair (JWT) Authentication

Snowflake supports key pair authentication using RSA-2048 private keys in PKCS#8 PEM format. The gosnowflake driver handles JWT generation, signing, and the 60-second token expiry internally.

database:
  type: snowflake
  connection_string: user@myaccount/mydb/public?warehouse=compute_wh&account=myaccount

  # Path to PKCS#8 PEM-encoded RSA private key
  private_key_path: /path/to/rsa_key.p8
  # Or provide the PEM content inline
  # private_key_pem: "-----BEGIN PRIVATE KEY-----\n..."

  # Optional: passphrase if the key is encrypted
  # key_passphrase: my-passphrase
OptionTypeDefaultDescription
private_key_pathstring-File path to PKCS#8 PEM private key
private_key_pemstring-Inline PEM content (alternative to file path)
key_passphrasestring-Passphrase for encrypted private keys

Setup steps:

  1. Generate an RSA-2048 key pair in PKCS#8 format:
    openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -out rsa_key.p8 -nocrypt
    
  2. Extract the public key:
    openssl rsa -in rsa_key.p8 -pubout -out rsa_key.pub
    
  3. Register the public key on your Snowflake user:
    ALTER USER my_user SET RSA_PUBLIC_KEY='MIIBIjANBg...';
    

Redshift

database:
  type: redshift
  # Redshift uses the PostgreSQL wire protocol. Use the standard connection
  # string form accepted by the configured SQL driver.
  connection_string: postgres://user:password@cluster.example.us-east-1.redshift.amazonaws.com:5439/dev?sslmode=require

Redshift support is experimental and Redshift-owned, not plain PostgreSQL compatibility. GraphJin uses Redshift-native catalog discovery, preferring SHOW TABLES and SHOW COLUMNS compatible metadata.

Supported Redshift v1 surfaces:

  • Queries and schema discovery.
  • Experimental single-table primary-key writes: insert, update, and delete.
  • Experimental batched polling subscriptions.
  • Experimental basic schema-diff DDL: create/drop table, add/drop column, informational PK/FK/unique constraints, DISTSTYLE AUTO, ENCODE AUTO, and sort keys.
  • Limited search over configured full_text columns using ILIKE predicates.
  • Limited GIS filters: st_dwithin, st_within, st_contains, and st_intersects.

Redshift limits:

  • Subscriptions use GraphJin batched polling. They are not native Redshift change streams and can be expensive for large warehouse queries.
  • Insert result selection requires a client-supplied primary key in v1; identity-only inserts are rejected.
  • Upsert, nested mutations, connect/disconnect, writable CTEs, generated identity return, user-managed indexes, and search indexes are not supported.
  • Redshift PK/FK/unique constraints are informational planner hints except for enforced NOT NULL.
  • Limited search is not indexed full-text ranking/headline support.
  • Limited GIS does not claim full PostGIS or complete Redshift spatial coverage.

ClickHouse

ClickHouse is a columnar OLAP engine. GraphJin treats it as a reads-first source: it compiles GraphQL to a batched read plan executed over the native protocol (port 9000), assembling nested JSON in Go (it does not use SQL JOIN/LATERAL).

database:
  type: clickhouse
  host: localhost
  port: 9000
  dbname: mydb
  user: default
  password: secret

  # Or use a DSN
  # connection_string: clickhouse://user:password@localhost:9000/mydb

ClickHouse specifics to be aware of:

  • No foreign keys. Relationships must be declared in config (a column's foreign_key), e.g. tables[].columns[]: { name: owner_id, foreign_key: users.id } — GraphJin cannot infer them as it does for SQL databases.
  • Reads. Filters (incl. or/not/like), ordering, limit/offset, keyset cursor pagination, and analytics (aggregates, distinct: group-by) are supported.
  • Mutations are best-effort and single-table. insert is first-class (pass the row as a JSON variable: insert: $data); update/delete run as synchronous ALTER … UPDATE / lightweight DELETE and require a where. No upsert, no nested/related writes, no RETURNING-style read-after-write guarantees for async paths.

TLS Connection Example

database:
  type: postgres
  host: mydb.example.com
  port: 5432
  dbname: myapp
  user: dbuser
  password: secret

  enable_tls: true
  server_name: "myproject:cloud-sql-instance"
  server_cert: ./certs/server-ca.pem
  client_cert: ./certs/client-cert.pem
  client_key: ./certs/client-key.pem

Catalog Graph Configuration

The catalog graph exposes GraphJin's discovered schema, workflow read data, and system guidance as ordinary read-only GraphQL tables backed by an internal nanoDB. It follows the deployment-mode default for system.capabilities.catalog.read; no source entry is required.

GraphJin-managed databases are intentionally excluded from catalog rows: the system graph itself and CodeSQL SQLite caches do not appear as application metadata, which prevents recursive self-description. CodeSQL can still be linked through gj_catalog to gj_code relationships.

system:
  capabilities:
    catalog.read: true
  root_access:
    gj_catalog: public
OptionDefaultDescription
system.capabilities.catalog.readmode defaultExposes catalog metadata through gj_catalog
system.root_access.gj_catalogmode defaultControls which caller class may query gj_catalog

Catalog item kinds include:

database
table
column
relationship
workflow
directive
operator_set
query_pattern
mutation_pattern
config
entrypoint
capability
system_capability

With CodeSQL enabled, you can query database catalog metadata and code usage in the same graph:

query {
  gj_catalog(where: { kind: { eq: "column" }, table_name: { eq: "users" }, column_name: { eq: "email" } }) {
    type
    gj_code {
      kind
      ref_kind
      path
      symbol_id
    }
  }
}

Authentication Configuration

Auth Options

OptionTypeDefaultDescription
auth.typestringnoneAuth type: none, jwt, header
auth.cookiestring-Name of the cookie holding the auth token
auth.developmentbooleanfalseEnable development mode (use headers for testing)

JWT Configuration

OptionTypeDefaultDescription
auth.jwt.providerstring-JWT provider: auth0, firebase, jwks, other
auth.jwt.secretstring-Secret key for HMAC signing
auth.jwt.public_keystring-Public key for RSA/ECDSA verification
auth.jwt.public_key_typestringecdsaPublic key type: ecdsa, rsa
auth.jwt.audiencestring-Expected audience claim value
auth.jwt.issuerstring-Expected issuer claim value
auth.jwt.jwks_urlstring-JWKS endpoint URL
auth.jwt.jwks_refreshinteger-JWKS refresh interval in minutes
auth.jwt.jwks_min_refreshinteger60JWKS minimum refresh interval in minutes

Header Authentication

OptionTypeDefaultDescription
auth.header.namestring-HTTP header name to check
auth.header.valuestring-Expected header value (optional)
auth.header.existsbooleanfalseOnly check if header exists

Authentication Examples

No Authentication

auth:
  type: none

JWT with Auth0

auth:
  type: jwt
  cookie: _myapp_session

  jwt:
    provider: auth0
    secret: your-secret-key-here
    audience: https://api.myapp.com
    issuer: https://myapp.auth0.com/

JWT with Public Key

auth:
  type: jwt

  jwt:
    provider: other
    public_key_type: rsa
    public_key: |
      -----BEGIN PUBLIC KEY-----
      MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
      -----END PUBLIC KEY-----
    audience: myapp
    issuer: https://auth.myapp.com

JWT with JWKS

auth:
  type: jwt

  jwt:
    provider: jwks
    jwks_url: https://myapp.auth0.com/.well-known/jwks.json
    audience: https://api.myapp.com
    issuer: https://myapp.auth0.com/
    jwks_refresh: 60  # Refresh every 60 minutes

Header Authentication

auth:
  type: header

  header:
    name: X-API-Key
    value: my-secret-api-key

Header Exists Check

auth:
  type: header

  header:
    name: X-Authenticated
    exists: true

Development Mode (Testing)

auth:
  type: jwt
  development: true  # Allows X-User-ID, X-User-Role headers for testing

  jwt:
    provider: auth0
    secret: dev-secret

When development: true, you can set user context via headers:

  • X-User-ID - Sets the user ID
  • X-User-Role - Sets the user role
  • X-User-ID-Provider - Sets the user ID provider

Core Compiler Configuration

OptionTypeDefaultDescription
secret_keystringautoSecret for encrypting cursors and opaque values
disable_allow_listbooleanfalseDisable the allow list workflow
enable_schemabooleanfalseGenerate/use database schema file
enable_introspectionbooleanfalseGenerate introspection JSON file
set_user_idbooleanfalseSet database session variable user.id
default_blockbooleantrueBlock all tables for anonymous users
default_limitinteger20Default row limit for queries
analytics_modebooleanfalseOLAP mode: skip implicit row limits and require partition filters on partitioned tables
modestringderived from productionDeployment mode: dev, prod, or agentic
subs_poll_durationduration5sSubscription polling interval
db_schema_poll_durationduration10sSchema change detection interval
disable_agg_functionsbooleanfalseDisable aggregation functions
disable_functionsbooleanfalseDisable all SQL functions
enable_camelcasebooleanfalseConvert camelCase to snake_case
mock_dbbooleanfalseReturn mock data without database
debugbooleanfalseEnable debug logging
log_varsbooleanfalseLog SQL query variable values

Example

secret_key: "your-32-char-secret-key-here!!"
disable_allow_list: false
enable_schema: false
enable_introspection: true
set_user_id: true
default_block: true
default_limit: 50
analytics_mode: false
subs_poll_duration: 2s
db_schema_poll_duration: 20s
disable_agg_functions: false
disable_functions: false
enable_camelcase: true
debug: false
log_vars: false

Analytics Mode

When analytics_mode: true, GraphJin compiles queries for OLAP / data-warehouse workloads:

  • No implicit row limit. default_limit is ignored — queries return all matching rows unless limit is set on the query or in the role's table config. Use this for analytics dashboards over Snowflake, BigQuery-style workloads, or any aggregate query where capping rows would corrupt results.
  • Partition filters required. On tables that declare a partition key, queries must filter on that key. Unfiltered scans are rejected at compile time to prevent unbounded warehouse scans.

In multi-database deployments you can set analytics_mode globally and override it per database — typically true for an analytics warehouse and false for an OLTP application database. See Multi-Database Configuration.

Analytics directives such as @running, @moving, @previous, and @rank are query syntax, not a separate config flag. They work well with analytics mode for business-reporting queries such as running totals, moving averages, row ranking, previous-period values, and first/last values in an ordered group. GraphJin validates support from the selected dialect and detected database version: MongoDB and known-old MySQL/MariaDB/SQLite/MSSQL versions fail at compile time with clear errors.


Security & Admin Configuration

OptionTypeDefaultDescription
disable_production_securitybooleanfalseDisable production security features

Rate Limiting

OptionTypeDefaultDescription
rate_limiter.ratefloat-Number of events per second
rate_limiter.bucketinteger-Maximum burst size
rate_limiter.ip_headerstring-Header containing client IP

Rate limiting uses the token bucket algorithm.

Example

rate_limiter:
  rate: 100      # 100 requests per second
  bucket: 20     # Allow bursts of up to 20 requests
  ip_header: X-Forwarded-For

MCP Configuration

Model Context Protocol (MCP) enables AI assistants to interact with GraphJin.

OptionTypeDefaultDescription
mcp.disablebooleanfalseDisable the MCP server
mcp.http_statefulbooleandev/agentic: true; prod: falseKeep Streamable HTTP sessions for server-initiated sampling
mcp.include_tools_with_agentbooleandev/agentic: true; prod: falseExpose primitive tools alongside ask_graphjin_agent; set false for an agent-only surface
mcp.allow_mutationsbooleantrueAllow mutation operations
mcp.allow_raw_queriesbooleantrueAllow arbitrary GraphQL queries
mcp.legacy_discoverybooleanfalseRe-enable legacy MCP discovery/action tools and legacy HTTP helper endpoints in source mode
mcp.stdio_user_idstring-Default user ID for stdio transport
mcp.stdio_user_rolestring-Default user role for stdio transport
mcp.onlybooleanfalseMCP-only mode; legacy HTTP helpers remain only when mcp.legacy_discovery is true
mcp.cursor_cache_ttlinteger1800Cursor cache TTL in seconds (30 min)
mcp.cursor_cache_sizeinteger10000Max in-memory cursor cache entries
mcp.allow_config_updatesbooleanfalseAllow LLMs to modify config (dangerous)
mcp.allow_schema_reloadbooleanfalseAllow schema reload via MCP (auto-enabled in dev mode)
mcp.allow_workflow_executionbooleanfalseAllow legacy execute_workflow MCP tool; GraphQL gj_workflow_execution is controlled by read_only table/source config
mcp.oauth.enabledbooleanfalseEnable OAuth metadata and challenges for hosted MCP clients
mcp.oauth.modestringexternalbuiltin reuses auth_login; external advertises configured authorization servers
mcp.oauth.resourcestringrequest URLExpected MCP resource/audience; defaults to the public /api/v1/mcp URL
mcp.oauth.issuerstringrequest originAuthorization server issuer in builtin mode
mcp.oauth.authorization_servers[]stringissuerAuthorization servers advertised in protected-resource metadata
mcp.oauth.scopes[]string["mcp"]OAuth scopes advertised and accepted for MCP
mcp.oauth.dynamic_client_registrationbooleantrueEnable dynamic client registration in builtin mode
mcp.oauth.client_id_metadata_documentsbooleantrueEnable CIMD client metadata discovery in builtin mode

Example

mcp:
  disable: false
  allow_mutations: true
  allow_raw_queries: false  # Only allow saved queries
  stdio_user_id: "system"
  stdio_user_role: "admin"
  cursor_cache_ttl: 3600
  cursor_cache_size: 5000
  allow_config_updates: false  # Keep disabled for security
  allow_schema_reload: true  # Enabled by default in dev mode
  allow_workflow_execution: false
  oauth:
    enabled: false
    mode: external
    scopes: ["mcp"]

Agent Configuration

The server-side GraphJin agent runs the catalog-first discovery loop inside GraphJin and returns a typed, evidence-backed answer. When enabled it is available at POST /api/v1/agent and as the MCP tool ask_graphjin_agent. The Dev and agentic modes enable it automatically. See AGENTIC.md for behavior.

It is an RLM (reasoning-with-code) loop built on Ax: the model writes JavaScript that calls the catalog tools and the typed result is parsed from key: value output. The model only needs to be good at code generation — it does not need provider tool-calling or structured-output modes — so any OpenAI-compatible endpoint works via agent.base_url.

OptionTypeDefaultDescription
agent.enabledbooleandev/agentic: true; prod: falseEnable the REST endpoint and MCP tool
agent.providerstringopenaiAx provider profile passed to NewAI (e.g. openai, openai-compatible, google-gemini)
agent.modelstring-Model name for the provider
agent.api_key_envstringOPENAI_API_KEYEnv var holding the provider API key; must be non-empty (use a dummy value for keyless local endpoints)
agent.base_urlstring-OpenAI-compatible provider base URL (e.g. a local or self-hosted endpoint)
agent.samplingstringautomaticDeprecated: omit it for automatic server-first resolution; off disables MCP client fallback
agent.max_stepsinteger8Maximum agent actor steps per request
agent.timeout_secondsinteger50Request timeout for agent runs; values below 50 are raised to the 50-second minimum
agent.read_onlybooleanfalseForce the server-side agent to reject mutations, including saved-query mutations
agent.return_tracebooleanfalseInclude agent action/trace data in responses
agent.seed_limitinteger10Initial query_catalog(search: instruction) seed row cap
agent.catalog_default_limitinteger20Default row limit for model-issued catalog queries

There are no per-request agent modes. The server-side agent's internal runtime always includes discovery, validation, saved-query execution, and guarded raw GraphQL execution. agent.read_only: true is the operator kill-switch that rejects mutations before execution, including saved-query mutations. Otherwise the agent always runs as the caller, so core roles, row-level security, source/table read_only, and protocol evidence gates decide what can run. The public MCP execute_graphql tool is separate and is listed only when mcp.allow_raw_queries: true.

For each request, a populated agent.api_key_env selects the server provider and prevents sampling. Without server credentials, MCP borrows a sampling-capable client's model; REST returns a missing-provider-key error.


Redis Configuration

redis.url is optional for a single GraphJin process. In a horizontal deployment it provides shared response-cache state and coordinates discovery and semantic-index builders. Discovery snapshots and vectors remain on the filesystem; Redis stores only leases, fencing tokens, active generation IDs, builder status, dirty counters, and PubSub notifications for those builders.

OptionTypeDefaultDescription
redis.urlstring-Redis connection URL

Example

redis:
  url: redis://localhost:6379/0

GraphJin Service enables cache-first schema discovery by default. It writes full-fidelity database metadata to immutable filesystem generations, validates their checksums and catalog revisions, and starts from the newest compatible activated generation on the next warm start. Direct users of the core Go package keep the existing live-first behavior.

Optional semantic search uses Ax embeddings as a recall layer over the catalog. It can find business concepts and likely table endpoints, but it never invents relationships: GraphJin follows only real, caller-visible metadata edges when adding a join path.

discovery_cache:
  enabled: true
  path: .graphjin/discovery
  refresh_interval: 5m
  startup_wait: 2m
  retain_generations: 2

catalog_search:
  semantic:
    enabled: false
    provider: openai
    embedding_model: text-embedding-3-small
    api_key_env: OPENAI_API_KEY
    base_url: ""
    dimensions: tiny

Discovery cache options

OptionTypeDefaultDescription
discovery_cache.enabledbooleantrueEnable service-owned cache-first discovery and coordinated refresh. Set false for the previous service live-first path.
discovery_cache.pathstring.graphjin/discoveryFilesystem root for immutable schema and semantic generations.
discovery_cache.refresh_intervalduration5mInterval between background live-schema checks.
discovery_cache.startup_waitduration2mMaximum cold-start or explicit-refresh wait for a coordinated activation.
discovery_cache.retain_generationsinteger2Number of valid activated generations retained when the filesystem supports deletion.

Each discovery generation contains per-source full schema snapshots, optional DDL, and a manifest with a secret-free config/source fingerprint, source and catalog revisions, file sizes, and SHA-256 checksums. Data files are written before the manifest, and an activation receipt is written only after successful activation. Partial, corrupt, or incompatible generations are ignored.

On a warm start GraphJin serves the filesystem generation immediately while a background refresh checks the live sources. If source revisions are unchanged, no new generation is activated. Explicit schema reloads and schema-affecting configuration changes use the same generation path and wait for activation. The running GraphJin pointer remains stable while its engine is rebuilt from the new snapshots.

For one GraphJin process, Redis is not required: discovery and semantic builds use in-process coordination. For multiple replicas, configure redis.url and mount discovery_cache.path as the same shared read-after-write filesystem on every replica. Redis coordinates one builder but never stores the schema or vectors.

When Redis is configured but unavailable:

  • A valid filesystem generation starts stale/degraded and rebuilding is suspended.
  • With no valid filesystem generation, startup fails after startup_wait so every replica does not run discovery independently.
  • A replica that cannot read a Redis-activated shared generation keeps its previous generation and logs degraded status.

Semantic catalog search options

OptionTypeDefaultDescription
catalog_search.semantic.enabledbooleanfalseEnable hybrid lexical and semantic catalog retrieval.
catalog_search.semantic.providerstringopenaiAx embedding provider name.
catalog_search.semantic.embedding_modelstring-Embedding model identifier. Required when semantic search is enabled.
catalog_search.semantic.api_key_envstringOPENAI_API_KEYEnvironment variable containing the embedding provider key.
catalog_search.semantic.base_urlstring-Optional provider-compatible endpoint override.
catalog_search.semantic.dimensionsstringtinytiny (128), small (256), medium (512), or default (provider-native).

Named dimensions are strict. A response with a different dimension is rejected and catalog queries fall back to lexical results. Provider, model, base URL, dimension preset, and semantic document format form the embedding-space fingerprint. Changing any of them causes a full rebuild; compatible catalog changes reuse vectors by canonical document hash and embed only added or changed documents.

The index is deliberately bounded. Each table produces one identity document, column facets grouped by meaning and chunked at 64 columns, and one real relationship-neighborhood document. Safe shared catalog concepts can also be embedded. Caller-owned artifacts, query/workflow bodies, evidence payloads, configuration values, examples containing values, and secrets remain lexical-only.

A 1,000-table catalog with 300 columns per table therefore produces roughly 12,000 vectors instead of 300,000. At tiny, raw vectors occupy about 6 MB. Document embedding uses batches of 64 with two concurrent requests. Warm startup performs no document-embedding calls.

Catalog queries remain lexical-first. Exact identifiers avoid a query embedding call and stay pinned above fused results. Other queries exact-scan the compact semantic index, discard low-confidence matches, map hits back to catalog cards, and optionally add at most two real relationship paths of three edges or fewer between up to four table endpoints. Original filters, visibility, ordering, limit, and offset are applied to the combined candidates. explain: true reports lexical, semantic table/facet, or deterministic relationship-path provenance. search_rank is the fused relevance rank; explicit non-relevance order_by sorts the hybrid union by the requested fields instead.

Query embeddings are never persisted. Each serving replica keeps a 1,024-entry in-memory LRU for ten minutes. Embedding errors and the two-second query timeout return lexical results instead of failing query_catalog.

See Discovery Cache And Semantic Search for the complete startup matrix, storage layout, safety boundaries, and operational checklist.


Caching Configuration

Response caching with automatic invalidation on mutations and stale-while-revalidate (SWR) for cheap stale serving.

OptionTypeDefaultDescription
caching.disablebooleanfalseDisable response caching entirely
caching.ttlinteger3600Hard TTL (seconds). Entries past this are dropped.
caching.fresh_ttlinteger300Soft TTL (seconds). Entries past this trigger background SWR refresh; set to 0 to disable SWR.
caching.exclude_tables[]string-Tables whose row changes won't invalidate cached queries (and queries touching them are not cached)

Backend selection

Caching is enabled by default with an in-memory LRU cache. Configure redis.url to switch to Redis for shared cache across instances:

redis:
  url: redis://localhost:6379/0
caching:
  ttl: 3600
  fresh_ttl: 300

Stale-while-revalidate semantics

When fresh_ttl < ttl and an entry is past fresh_ttl but inside ttl:

  1. The stale response is returned to the caller immediately
  2. A background worker re-runs the query under the original role and overwrites the cache entry
  3. Concurrent refreshes for the same key are deduplicated via singleflight
  4. The worker pool is bounded so a thundering herd of stale hits cannot spawn unbounded goroutines

Set fresh_ttl: 0 (or equal to ttl) to disable SWR — entries are then served fresh until the hard expiry.

Invalidation

Mutations that touch a row drop every cached query that depended on that row. Tracking is row-level for small results (≤500 rows) and falls back to table-level for larger results, so analytic queries don't pay row-tracking overhead.

The cache key is derived from operation name, query text, variables, role, and (for ABAC) user ID. Anonymous queries (no operation name, no APQ key) bypass the cache entirely.

Example

caching:
  disable: false
  ttl: 3600        # 1 hour hard expiry
  fresh_ttl: 300   # 5 minute fresh window; stale-but-valid for the next 55 min
  exclude_tables:
    - audit_logs
    - sessions

Uploads Configuration

GraphQL endpoint accepts multipart/form-data POSTs per the graphql-multipart-request-spec. See the File Uploads feature reference for the on-the-wire shape.

OptionTypeDefaultDescription
uploads.enabledbooleanfalseAccept multipart/form-data POSTs on /api/v1/graphql
uploads.max_sizeinteger26214400 (25 MB)Per-request body limit, in bytes
uploads.allowed_mime[]string-When non-empty, restricts content types. Glob-aware (image/*, application/pdf)
uploads.storagestring-Filesystem table name to stream into. When empty, files are inlined as base64 in the variable.
uploads.storage_key_prefixstring-Prefix for streamed-file keys. {date} is substituted with YYYY/MM/DD at request time.

Two modes

Inline base64 (storage empty) — the multipart file becomes a JSON object inside the variable: {filename, content_type, size, data}. Useful for small uploads going into a bytea column.

Streamed to a filesystem table (storage: avatars) — the body is written to the named filesystem source and the variable becomes {key, content_type, size, url, etag, modified_at}. Keeps large bodies out of the request/response.

Example

uploads:
  enabled: true
  max_size: 25_000_000
  allowed_mime: ["image/*", "application/pdf"]
  storage: avatars                 # name of a filesystem source/table
  storage_key_prefix: "{date}/"    # → 2026/05/08/<8-byte-hex>.png

Filesystems Configuration

Object stores (local directories, S3, GCS) exposed as virtual GraphQL tables. They use the normal read surface (id, where, order_by, limit, offset, cursor-style page args) over portable object metadata; see the Filesystem Tables feature reference for examples.

Declare each object store as sources[].kind: file, then attach the table to that source:

sources:
  - name: avatars
    kind: file
    backend: s3
    bucket: app-assets
    prefix: avatars/

tables:
  - name: avatars
    source: avatars
    read_only: true
OptionTypeDefaultDescription
namestringrequiredTable name surfaced in GraphQL
backendstringrequiredlocal, s3, or gcs
bucketstring-S3/GCS bucket (required for those backends)
regionstring-S3 region
endpointstring-S3 endpoint override (MinIO, localstack, R2, etc.) — forces path-style addressing
prefixstring-Prepended to every key (effective root for the table)
rootstring-Local filesystem root directory (required for local)
presign_ttlduration15mTTL for generated presigned URLs
public_base_urlstring-When set, replaces presigned URLs with <base>/<key> — useful for CDN fronting
max_list_page_sizeinteger1000Caps limit: for list queries

Authentication

Each backend uses its platform's standard credential chain — never embedded in GraphJin config:

  • S3: env vars (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY), ~/.aws/credentials, IRSA on EKS, EC2 IMDS
  • GCS: Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS env, GCE/GKE metadata server, gcloud auth application-default login)
  • Local: filesystem permissions of the GraphJin process

Build-tag gating

Slim builds drop SDK weight:

  • -tags no_s3 — excludes the S3 backend (drops AWS SDK from the binary)
  • -tags no_gcs — excludes the GCS backend (drops Google Cloud SDK from the binary)
  • local is always built in

Configuring an excluded backend produces a clear startup error.

Custom backends

Plug in Azure Blob, Cloudflare R2, or anything implementing fstable.Backend via core.OptionSetFilesystemBackend(name, factory) from your own setup code.

Examples

sources:
  - name: avatars
    kind: file
    backend: s3
    bucket: my-bucket
    prefix: avatars/
    region: us-east-1
    presign_ttl: 15m
    public_base_url: https://cdn.example.com

  - name: invoices
    kind: file
    backend: gcs
    bucket: invoices
    prefix: 2026/

  - name: uploads_local
    kind: file
    backend: local
    root: /var/lib/graphjin/uploads

  # MinIO / localstack via S3-compatible endpoint:
  - name: dev_blob
    kind: file
    backend: s3
    bucket: dev
    region: us-east-1
    endpoint: http://localhost:9000

tables:
  - name: avatars
    source: avatars
    read_only: true

  - name: invoices
    source: invoices
    read_only: true

  - name: uploads_local
    source: uploads_local

  - name: dev_blob
    source: dev_blob

Federation Configuration

Register GraphJin as an Apollo Federation v2 subgraph so it composes alongside other services behind Apollo Router / Cosmo / Hive Gateway. See the Apollo Federation v2 feature reference for the SDL surface.

OptionTypeDefaultDescription
federation.enabledbooleanfalseEmit federation SDL and answer _service { sdl } queries
federation.versionstringv2.5Federation spec version (the value used in the @link directive)
federation.keysmap-Per-table override for @key(fields: ...). Auto-derived from primary keys when absent.
federation.shareable[]string-Type.field identifiers tagged @shareable
federation.inaccessible[]string-Type.field identifiers tagged @inaccessible
federation.tagsmap-Type.field → list of @tag(name:...) annotations

When enabled: true:

  • _service { sdl } returns a federation-flavoured SDL covering every non-blocked, primary-keyed table
  • _entities(representations: [_Any!]!) is recognised but currently returns a clear "not yet implemented" error — composition still succeeds in the gateway
  • All other queries flow through the regular pipeline unchanged. Detection is a token-bounded substring scan over the raw query.

Example

federation:
  enabled: true
  version: "v2.5"

  keys:
    users:  ["id"]                   # default — auto-derived
    orders: ["id", "tenant_id"]      # composite key override

  shareable:
    - Tag.name

  inaccessible:
    - Users.encrypted_password
    - Users.reset_password_token

  tags:
    Users.full_name: ["pii"]
    Users.email:     ["pii", "exportable"]

Schema Configuration

Variables

Define variables for use in queries and filters.

# Static variables
variables:
  admin_account_id: "5"
  default_status: "active"

# SQL-based variables (prefixed with "sql:")
variables:
  admin_id: "sql:select id from users where admin = true limit 1"

Header Variables

Map HTTP headers to variables.

header_variables:
  remote_ip: "X-Forwarded-For"
  tenant_id: "X-Tenant-ID"

Blocklist

Block specific tables or columns from all queries.

blocklist:
  - ar_internal_metadata
  - schema_migrations
  - secret
  - password
  - encrypted
  - token

Tables Configuration

Configure table aliases, relationships, and column metadata.

OptionTypeDescription
namestringVirtual table name (used in queries)
tablestringActual database table name
schemastringDatabase schema
typestringTable type: polymorphic, jsonb
databasestringDatabase name (for multi-db)
blocklist[]stringColumns to block for this table
order_bymapNamed order-by presets
columns[]ColumnColumn configurations

Column Configuration

OptionTypeDescription
namestringColumn name
typestringColumn type (e.g., integer, text, bigint)
primarybooleanMark as primary key
arraybooleanColumn is an array type
full_textbooleanEnable full-text search
related_tostringForeign key relationship (e.g., users.id)

Tables Examples

tables:
  # Table alias - query "me" maps to "users" table
  - name: me
    table: users

  # Custom order_by presets
  - name: users
    order_by:
      new_users: ["created_at desc", "id asc"]
      by_id: ["id asc"]

  # Column relationships (for arrays without foreign keys)
  - name: products
    columns:
      - name: category_ids
        related_to: categories.id
    order_by:
      price_and_id: ["price desc", "id asc"]

  # Polymorphic table
  - name: subject
    type: polymorphic
    columns:
      - name: subject_id
        related_to: subject_type.id

  # Self-referential relationship
  - name: chats
    columns:
      - name: reply_to_id
        related_to: chats.id

  # JSONB column type
  - name: category_counts
    table: users
    type: jsonb
    columns:
      - name: category_id
        related_to: categories.id
        type: bigint
      - name: count
        type: integer

Functions Configuration

Configure custom database functions.

functions:
  - name: calculate_total
    schema: public
    return_type: numeric

  - name: get_user_permissions
    return_type: record

Resolvers Configuration

Configure remote API resolvers to join external data into queries.

resolvers:
  - name: payments
    type: remote_api
    table: customers
    column: stripe_id
    json_path: data
    debug: false
    url: http://payments-service/payments/$id
    pass_headers:
      - cookie
      - authorization
    set_headers:
      - name: Host
        value: payments-service
      - name: X-API-Key
        value: ${PAYMENTS_API_KEY}

OpenAPI Integration

GraphJin can join data from any HTTP API that publishes an OpenAPI 3 specification. Drop the spec into config/specs/, declare credentials and join wiring in your environment config (dev.yml/prod.yml), and the API's GET endpoints become joinable from your existing tables — without writing a custom resolver per integration.

The OpenAPI integration is the spec-driven counterpart to remote_api (above). Use remote_api for ad-hoc URL joins; use OpenAPI when the upstream publishes a spec and you want auth, parameter wiring, and response shape derived automatically.

Quick Start

  1. Drop an OpenAPI 3 spec into config/specs/<name>.yaml:

    config/
      dev.yml
      specs/
        interaction_studio.yaml    # vendor-supplied, untouched
    
  2. Add an API source for OpenAPI specs to your environment config (dev.yml/prod.yml):

    sources:
      - name: app
        kind: database
        type: postgres
        connection_string: ${APP_DATABASE_URL}
        default: true
    
      - name: upstream
        kind: api
        specs_dir: ./config/specs
        specs:
          interaction_studio:
            base_url: https://${IS_ACCOUNT}.personalization.salesforce.com/api
            auth:
              scheme: token_exchange
              token_url: https://${IS_ACCOUNT}.personalization.salesforce.com/api/token
              request:
                body:
                  apiKeyId: ${IS_API_KEY}
                  apiKeySecret: ${IS_API_SECRET}
              response:
                token_field: access_token
                expires_field: expires_in
    
            joins:
              getUserById:
                parent_table: users
                parent_column: email
                param: userId
                expose_as: is_profile
    
    tables:
      - name: users
        source: app
    
  3. Restart GraphJin. Boot logs report what was loaded:

    openapi: loaded interaction_studio.yaml (5 active, 12 skipped)
    openapi: GET /exports/{jobId} — skipped: non-JSON response (application/octet-stream)
    
  4. Query as a child field on the parent table:

    query {
      users(where: { id: { eq: 42 } }) {
        id
        email
        is_profile {
          lastSeenAt
          segments { id name }
        }
      }
    }
    

Operation Classification

Every GET in the spec is classified into one of:

ModePath shapeBehaviour
Row-joinGET /resource/{id} with a matching joins: entryExposed as a child field on the parent DB table. The parent column's value populates the path parameter at query time.
Top-level (single)GET /resource/{id} without a joins: entryExposed as a top-level GraphQL field. The path parameter becomes a required field argument.
Top-level (list)GET /resources with optional query filtersExposed as a top-level GraphQL field. Each query parameter becomes an optional field argument.
SkippedAsync (Location header), binary response, mutating verb (POST/PUT/PATCH/DELETE), multi-segment path params, or single non-trailing path param without an explicit opt-inReason logged at boot. Single non-trailing path params can be opted in with expose_top_level: true (see Operation Overrides).

Top-level virtual tables

Operations classified as top-level are queryable directly, with no parent DB row required:

# Single-record by ID — path param becomes a required arg
query {
  is_get_user_by_id(userId: "u-42") {
    id
    email
    lastSeenAt
  }
}

# Collection — query params become args
query {
  is_audit_logs(actorId: "u-42") {
    id
    action
  }
}

# Mixed — DB tables and OpenAPI virtuals in one query
query {
  users(where: { id: { eq: 1 } }) { id email }
  is_audit_logs(actorId: "u-1") { id action }
}

Default GraphQL field name is <spec_key>_<operation_id_snake>; override per-operation under operations::

sources:
  - name: upstream
    kind: api
    specs:
      is:
        operations:
          listAuditLogs:
            expose_as: audit_logs

Authentication

Configure auth per spec under the auth: block. All credential-bearing fields support ${VAR} env-var expansion at load time.

Bearer (static or pass-through)

auth:
  scheme: bearer
  token: ${API_TOKEN}
  # Or, for multi-tenant deployments, forward the token from an inbound header:
  # token_from_request:
  #   header: X-User-Token

Basic

auth:
  scheme: basic
  username: ${API_USER}
  password: ${API_PASS}

API Key (header or query)

auth:
  scheme: api_key
  key_name: X-API-Key
  key_value: ${API_KEY}
  key_in: header   # or "query"

OAuth2 client_credentials

GraphJin fetches an access token at startup and refreshes it before expiry.

auth:
  scheme: oauth2_client_credentials
  token_url: https://auth.example.com/oauth/token
  client_id: ${CLIENT_ID}
  client_secret: ${CLIENT_SECRET}
  scopes: [read]

token_exchange (vendor-specific flows)

For vendors that don't follow OAuth2 — including Salesforce Marketing Cloud Personalization (formerly Interaction Studio) — token_exchange lets you describe the request body shape and response field names directly:

auth:
  scheme: token_exchange
  token_url: https://auth.example.com/api/token
  request:
    method: POST           # default
    body_format: json      # or "form"
    body:
      apiKeyId: ${API_KEY_ID}
      apiKeySecret: ${API_SECRET}
    headers:
      X-Custom-Header: foo
  response:
    token_field: access_token
    expires_field: expires_in
  cache_ttl: 3500s         # override when response omits expires_in

Per-Spec Concurrency

Each spec gets its own concurrency budget, applied collectively across every operation against that spec:

sources:
  - name: upstream
    kind: api
    specs:
      interaction_studio:
        concurrency:
          max_concurrent: 8           # in-flight requests cap (default: 8)
          rate_limit_per_second: 50   # token-bucket RPS cap (default: 50)

Without these caps, a 1000-row parent select would spawn 1000 parallel requests upstream — a reliable way to get rate-limited.

Operation Overrides

Tweak per-operation presentation:

sources:
  - name: upstream
    kind: api
    specs:
      interaction_studio:
        operations:
          listAuditLogs:
            expose_as: audit_logs       # rename auto-derived field
            result_path: data.records   # override auto-detected wrapper path
            disabled: false             # set true to hide an operation entirely
          exportUsers:
            expose_top_level: true      # opt-in classification (see below)
            expose_as: is_users

expose_top_level

Operations whose path has a single non-trailing path parameter — for example GET /api/dataset/{datasetId}/users.json — are skipped by default because the semantics ("nested resource" vs "list scoped by an arg") aren't safe to auto-infer. Set expose_top_level: true to opt the operation in:

  • The path parameter becomes a required GraphQL field argument.
  • Mode is chosen from the response shape (array → list, object → single record).
  • Result-path stripping and field filtering work exactly as for auto-classified top-level ops.
{ is_users(datasetId: "abc", pageSize: "200") { items { id email } } }

expose_top_level does not opt-in operations that are skipped for other reasons (mutating verb, async, non-JSON, multi-segment path params).

Result Path Auto-Detection

When the upstream wraps responses in {data: [...]}, {items: [...]}, {results: [...]}, or {records: [...]}, GraphJin strips the wrapper automatically. Other shapes need an explicit result_path: under operations:.

Naming Defaults

Auto-derived field names use <spec_key>_<operationId_snake>:

  • Spec file interaction_studio.yaml + operation getUserByIdinteraction_studio_get_user_by_id

Override per-operation via expose_as: (under joins: for row-joins, under operations: for everything else).

Collision Defence

GraphJin validates synthesised remote tables at boot so an OpenAPI op cannot silently shadow a real table.

CollisionBehaviour
expose_as matches a real (non-remote) table in the primary schemaHard fail at boot. Error names the operation and the YAML path to fix.
Two operations resolve to the same expose_asHard fail at boot. Error names both operations.
expose_as matches a configured table aliasWarn. OpenAPI remote takes precedence; alias still queryable under its other name.
expose_as matches a user-declared resolvers: entryWarn. Registration order decides; review and rename if needed.

Default <spec_key>_<operationId_snake> namespacing makes these rare. Overrides via expose_as are the usual cause.

Example error:

openapi: operation interaction_studio/exportUsers exposes as "users" which collides with a real table in schema public; set 'expose_as' under openapi.interaction_studio.joins.exportUsers to a unique name

Boot Log Output

GraphJin reports the OpenAPI integration state at startup so you can verify what's loaded before queries arrive:

openapi: loaded interaction_studio.yaml (5 active, 12 skipped)
openapi: GET /exports/{jobId} — skipped: non-JSON response (application/octet-stream)
openapi: GET /jobs/{jobId}/status — skipped: async pattern (Location header on success response)
openapi: POST /users — skipped: mutating verb (write-side not yet supported)
openapi: GET /api/dataset/{datasetId}/users.json — skipped: path parameter not in trailing position (set expose_top_level: true to opt in)

Limitations

  • Read-only — only GET operations are processed. Mutations (POST/PUT/PATCH/DELETE) are logged and skipped. Direct write support is planned.
  • Async/export endpoints — out of scope. GraphJin is a query engine, not a data-replication system. Job-based or file-download endpoints are skipped at classification.
  • Header pass-through — multi-tenant header forwarding (token_from_request) is wired through the auth provider but the bridge layer to inbound HTTP headers isn't yet plumbed. Static ${ENV} tokens work today.
  • Top-level args are scalar literals only — quote numbers and booleans (limit: "50"). Nested object/array args are not supported in v1.

Role-Based Access Control

Roles Query

Dynamically assign roles based on user attributes.

# The query receives $user_id as a parameter
# SQL mode: column names become context values for role matching
roles_query: "SELECT role, org_id FROM user_roles WHERE user_id = $user_id:bigint"

roles_query can also be written as a GraphQL query. GraphQL mode is selected when the value starts with {, query, or fragment. The query must return zero rows or one object from a single root field; returning more than one row is treated as a configuration error. In GraphQL mode, match is evaluated against the returned object in role configuration order:

roles_query: |
  query {
    users(id: $user_id) {
      role
      userid: id
      account {
        tier
      }
    }
  }

roles:
  - name: admin
    match: role = 'the_admin_dude' or userid < 10 or account.tier = 'enterprise'

GraphQL match supports a portable predicate subset: identifiers and dotted paths, string/number/boolean/null literals, =, !=, <>, <, <=, >, >=, and, or, not, parentheses, is null, and is not null. SQL functions and casts are available only with SQL roles_query.

Role Configuration

OptionTypeDescription
namestringRole name (e.g., user, admin, anon)
matchstringRole predicate. SQL condition in SQL roles_query mode; portable predicate over returned fields in GraphQL roles_query mode.
commentstringDescription of the role
tables[]RoleTablePer-table configurations

Default Roles

  • anon - Anonymous users (no authentication)
  • user - Authenticated users (user ID present)

Per-Table Role Configuration

OperationOptions
querylimit, filters, columns, disable_functions, block
insertfilters, columns, presets, block
updatefilters, columns, presets, block
upsertfilters, columns, presets, block
deletefilters, columns, block

Role Configuration Examples

roles:
  # Anonymous users - restricted access
  - name: anon
    tables:
      - name: products
        query:
          limit: 10
          columns: [id, name, description, price]

      - name: categories
        query:
          limit: 50

  # Authenticated users
  - name: user
    tables:
      # Users can only query their own data
      - name: me
        query:
          filters: ["{ id: { _eq: $user_id } }"]

      - name: products
        query:
          limit: 50
          filters: ["{ published: { _eq: true } }"]

        insert:
          columns: [name, description, price, category_id]
          presets:
            - user_id: "$user_id"
            - created_at: "now"
            - updated_at: "now"

        update:
          filters: ["{ user_id: { _eq: $user_id } }"]
          columns: [name, description, price]
          presets:
            - updated_at: "now"

        delete:
          filters: ["{ user_id: { _eq: $user_id } }"]

  # Admin role (matched via roles_query)
  - name: admin
    match: role = 'admin'
    tables:
      - name: users
        query:
          # No filters - admins can see all users
          limit: 100

        update:
          columns: [name, email, role, active]

        delete:
          # Admins can delete any user
          block: false

  # Organization-specific role
  - name: org_member
    match: org_id IS NOT NULL
    tables:
      - name: projects
        query:
          filters: ["{ org_id: { _eq: $org_id } }"]

        insert:
          presets:
            - org_id: "$org_id"
            - created_by: "$user_id"

Blocking Operations

Use block: true to completely disable an operation for a role:

roles:
  - name: user
    tables:
      - name: audit_logs
        query:
          block: true  # Users cannot query audit logs
        insert:
          block: true  # Users cannot insert audit logs
        update:
          block: true
        delete:
          block: true

Multi-Database Configuration

GraphJin supports querying across multiple SQL databases in a single GraphQL request. databases: is the legacy database-only spelling and remains supported when sources: is absent. In source mode, declare these same databases as sources[].kind: database.

Database Map Structure

databases:
  primary:
    type: postgres
    host: localhost
    port: 5432
    dbname: myapp
    user: postgres
    password: secret
    schema: public
    max_open_conns: 25
    max_idle_conns: 5

  analytics:
    type: postgres
    host: analytics-db.example.com
    port: 5432
    dbname: analytics
    user: readonly
    password: secret
    read_only: true  # Blocks all mutations and DDL against this database
    tables:
      - events
      - metrics

  legacy:
    type: mysql
    host: legacy-db.example.com
    port: 3306
    dbname: legacy_app
    user: app_user
    password: secret

CodeSQL is no longer configured under databases:. Move source-code indexes to sources[].kind: code.

Per-Database Read-Only Mode

Set read_only: true on a database to block all mutations and DDL (schema changes) against it. This is useful for production/reporting databases that should never be modified by an LLM or application code.

databases:
  production_replica:
    type: postgres
    host: replica.example.com
    dbname: myapp
    read_only: true  # No mutations or DDL allowed

Tamper protection: Once read_only: true is set in the config file, MCP tools (including update_current_config) cannot change it to false at runtime. The value is snapshotted at startup and enforced immutably.

When a database is read-only:

  • apply_schema_changes targeting that database returns an error
  • All tables in the database inherit read_only: true for role-level enforcement
  • update_current_config preserves the read_only: true flag even if the LLM tries to change it

Per-Database Analytics Mode

analytics_mode can be set globally on the top-level config and overridden per database. This is the typical setup when GraphJin fronts both an OLTP application DB and an analytics warehouse:

analytics_mode: false  # default for app DBs

databases:
  app:
    type: postgres
    host: app-db.example.com
    dbname: myapp
    # inherits analytics_mode: false

  warehouse:
    type: snowflake
    host: xy12345.snowflakecomputing.com
    dbname: ANALYTICS
    analytics_mode: true  # OLAP rules for this DB only

The per-database value, when set, fully overrides the top-level value. See Analytics Mode for what the flag does.

Assigning Tables to Databases

You can assign tables to databases in two ways:

1. In the database config

databases:
  analytics:
    type: postgres
    host: analytics-db.example.com
    # ...
    tables:
      - events
      - metrics
      - user_activity

2. In the table config

tables:
  - name: events
    database: analytics

  - name: legacy_users
    table: users
    database: legacy

Cross-Database Relationships

Tables in different databases can have foreign key relationships. GraphJin automatically detects cross-database relationships and handles them via result stitching (similar to remote API joins but for in-process database calls).

Configure a cross-database foreign key using related_to in the table config:

databases:
  primary:
    type: postgres
  analytics:
    type: sqlite
    host: /data/analytics.db
    tables:
      - user_events

tables:
  - name: user_events
    database: analytics
    columns:
      - name: user_id
        related_to: users.id   # 'users' is in the primary database

Now queries that join across these databases work transparently:

query {
  users {
    name
    user_events {   # Fetched from analytics DB automatically
      event_type
      created_at
    }
  }
}

How it works: GraphJin executes the parent query, extracts foreign key values from the result, builds a filtered query for the child table in the target database, executes it, and merges the child data into the parent response. Null foreign keys produce null child results gracefully.

Environment Variables for Multiple Databases

Environment variables can override any nested config key using the GJ_ prefix. Underscores are progressively converted to dots to match config paths.

Important: The key must already exist in your config file for the environment variable to take effect.

Example: Override multi-database settings

Config file with placeholders:

databases:
  primary:
    type: postgres
    host: ""        # Overridden by GJ_DATABASES_PRIMARY_HOST
    port: 5432      # Overridden by GJ_DATABASES_PRIMARY_PORT
    dbname: myapp
    user: ""        # Overridden by GJ_DATABASES_PRIMARY_USER
    password: ""    # Overridden by GJ_DATABASES_PRIMARY_PASSWORD

  analytics:
    type: postgres
    host: ""        # Overridden by GJ_DATABASES_ANALYTICS_HOST
    dbname: analytics
    user: ""        # Overridden by GJ_DATABASES_ANALYTICS_USER
    password: ""    # Overridden by GJ_DATABASES_ANALYTICS_PASSWORD

Environment variables:

export GJ_DATABASES_PRIMARY_HOST=primary-db.example.com
export GJ_DATABASES_PRIMARY_PORT=5432
export GJ_DATABASES_PRIMARY_USER=app_user
export GJ_DATABASES_PRIMARY_PASSWORD=secret123

export GJ_DATABASES_ANALYTICS_HOST=analytics-db.example.com
export GJ_DATABASES_ANALYTICS_USER=readonly_user
export GJ_DATABASES_ANALYTICS_PASSWORD=analytics_secret

How the mapping works

The GJ_ prefix is stripped, then underscores are converted to dots until a matching config key is found:

Environment VariableConfig Path
GJ_DATABASES_PRIMARY_HOSTdatabases.primary.host
GJ_DATABASES_PRIMARY_PORTdatabases.primary.port
GJ_DATABASES_ANALYTICS_PASSWORDdatabases.analytics.password

Environment Variables Reference

Database Variables

VariableMaps To
GJ_DATABASE_HOSTdatabase.host
GJ_DATABASE_PORTdatabase.port
GJ_DATABASE_USERdatabase.user
GJ_DATABASE_PASSWORDdatabase.password
GJ_DATABASE_NAMEdatabase.dbname
GJ_DATABASE_SCHEMAdatabase.schema

Authentication Variables

VariableMaps To
GJ_AUTH_JWT_SECRETauth.jwt.secret
GJ_AUTH_JWT_PUBLIC_KEY_FILEauth.jwt.public_key (file path)

Service Variables

VariableMaps To
GO_ENVConfig file and default mode selection (agentic loads required agentic.yml with mode: agentic)
HOSThost
PORTport

Complete Examples

Development Configuration

app_name: "My App Development"
host_port: 0.0.0.0:8080
web_ui: true
production: false
log_level: "debug"
log_format: "plain"
http_compress: true
server_timing: true
enable_tracing: true
auth_fail_block: false
reload_on_config_change: true
debug: true

secret_key: dev-secret-key-change-in-prod

cors_allowed_origins: ["*"]
cors_debug: false

subs_poll_duration: 2s
default_limit: 20
default_block: false

hot_deploy: false
admin_secret_key: dev-admin-key

auth:
  type: none
  development: true

database:
  type: postgres
  host: localhost
  port: 5432
  dbname: myapp_development
  user: postgres
  password: postgres
  pool_size: 10
  ping_timeout: 1m

variables:
  admin_id: "sql:select id from users where admin = true limit 1"

header_variables:
  remote_ip: "X-Forwarded-For"

blocklist:
  - ar_internal_metadata
  - schema_migrations
  - password
  - secret

tables:
  - name: me
    table: users

roles:
  - name: user
    tables:
      - name: me
        query:
          filters: ["{ id: { _eq: $user_id } }"]

Production Configuration

inherits: dev

app_name: "My App Production"
host_port: 0.0.0.0:8080
web_ui: false
mode: prod
log_level: "warn"
log_format: "json"
http_compress: true
enable_tracing: false
auth_fail_block: true
reload_on_config_change: false

hot_deploy: true
# Use environment variable for admin secret
# admin_secret_key: ${GJ_ADMIN_SECRET_KEY}

auth:
  type: jwt
  cookie: _myapp_session
  development: false

  jwt:
    provider: jwks
    jwks_url: https://myapp.auth0.com/.well-known/jwks.json
    audience: https://api.myapp.com
    issuer: https://myapp.auth0.com/

database:
  type: postgres
  host: ${GJ_DATABASE_HOST}
  port: 5432
  dbname: myapp_production
  user: ${GJ_DATABASE_USER}
  password: ${GJ_DATABASE_PASSWORD}
  pool_size: 25
  max_connections: 50
  ping_timeout: 5m
  enable_tls: true
  server_cert: /etc/ssl/certs/db-ca.pem

Multi-Database Configuration

app_name: "Multi-DB App"
host_port: 0.0.0.0:8080
production: false

database:
  type: postgres
  host: localhost
  port: 5432
  dbname: primary_db
  user: postgres
  password: postgres

databases:
  primary:
    type: postgres
    host: localhost
    port: 5432
    dbname: primary_db
    user: postgres
    password: postgres
    max_open_conns: 25

  analytics:
    type: postgres
    host: analytics.example.com
    port: 5432
    dbname: analytics_db
    user: readonly
    password: secret
    tables:
      - events
      - page_views
      - user_sessions

  legacy:
    type: mysql
    host: legacy.example.com
    port: 3306
    dbname: legacy_app
    user: app
    password: secret

tables:
  - name: legacy_customers
    table: customers
    database: legacy
    columns:
      - name: user_id
        related_to: users.id

auth:
  type: jwt
  jwt:
    provider: other
    secret: your-secret-key

Dev vs Production Recommendations

SettingDevelopmentProduction
modedevprod
web_uitruefalse
log_leveldebugwarn or info
log_formatplainjson
enable_tracingtruefalse
auth_fail_blockfalsetrue
reload_on_config_changetruefalse
debugtruefalse
hot_deployfalsetrue
auth.developmenttruefalse
cors_allowed_origins["*"]Specific origins
disable_allow_listtruefalse