Skip to content

Changelog ​

Releases are tagged vX.Y.Z; each one has generated notes on the Releases page. This file records the highlights.

Pull requests do not edit this file: each adds its entry to changelog.d/, collected here at release.

1.2.0 - 2026-10-18 ​

Full support for pg_jsonschema, and fixes to the generated contract: what the database validates now reaches the OpenAPI create and update bodies, defaults are values rather than SQL, and checks on varchar and domain columns are read. Dev mode keeps the schema and contracts current after more kinds of change.

Upgrading. No configuration changes are needed. The generated GraphQL SDL and OpenAPI document change wherever a fix or an addition applies, so re-export pinned schemas (graphpg schema export) before deploying with export.pin: fail. In detail: OpenAPI defaults change or disappear; Input.* body fields gain the keywords of their checks, and a NOT NULL column's field no longer allows null (the database always refused it); varchar and domain columns gain pattern, length and bound keywords, and the SDL's @constraint; Json.* components appear; and <Type>ErrorCode gains <COLUMN>_INVALID for columns of a checked domain. These are additions or corrections, as versioning allows in a minor release.

pg_jsonschema ​

  • Complete support (#217, guide). A json / jsonb column checked by jsonb_matches_schema('<schema>', column) was typed in the OpenAPI row schema only, and only for that exact form of check. Now:
    • Write bodies carry the schema too, nested writes included (#215, below).
    • Domains: a domain checked by jsonb_matches_schema('…', VALUE) types every column declared with it, through one component, Json.<Domain>. Such a domain in a schema other than the exposed ones also turns the integration on.
    • References: a schema with $defs (or definitions) and "$ref": "#/…" becomes a component of its own, Json.<table>.<column>, and its references resolve in the document. Before, they pointed at the document's root and did not resolve.
    • Older drafts: a schema declaring draft-04, -06, -07 or 2019-09 in $schema is rewritten where JSON Schema 2020-12 (the dialect of OpenAPI 3.1) reads it differently: tuple items, additionalItems, dependencies, draft-04's boolean exclusive bounds, and keywords beside a $ref.
    • More forms of check: column IS NULL OR jsonb_matches_schema(…), and the call AND other conditions, type the column.
    • Errors that say why: the error for a rejected value carries validationErrors (in GraphQL extensions and in the REST problem document), with each failed keyword and a JSON Pointer to where in the value it failed. In a bulk write, fields names the failing row. The database still decides whether a value is valid: graphpg validates the refused value with the validator pg_jsonschema itself uses, only to explain the refusal.
  • Fixed: a value a domain's CHECK rejects answered 500. This affected any domain, for example CREATE DOMAIN zip AS text CHECK (VALUE ~ '^\d{5}$'), because PostgreSQL names the domain in the error rather than the table and column. It is now <COLUMN>_INVALID (REST 422) on the column and row written, and is listed in <Type>ErrorCode. A domain's checks also give its columns their OpenAPI keywords and the SDL's @constraint, as a column's own checks do.

Generated contract ​

  • Fixed: the OpenAPI create and update bodies lost what the database validates (#215). The document served at /openapi.json, written by graphpg schema export and checked by export.pin describes bodies with the GraphQL input types, which typed a pg_jsonschema column as any JSON and dropped every CHECK keyword (bounds, multipleOf, length, pattern, item counts). Their column fields now have the row column's schema, nested inputs included, and relation members keep their shape. An update body has no default, because a field left out of an update keeps its value.
  • Fixed: OpenAPI defaults were SQL text (#213), such as "false", "5", "'active'::text" and "uuidv7()", in row and request schemas. A constant default is now a JSON value of the property's type: false, 5, "active"; a 64-bit or decimal default as a string, an enum default as the enum's API name, and a json default as the value. A default PostgreSQL computes (now(), uuidv7(), nextval(…), any expression) has no default; the database still fills it in. Nothing is evaluated during introspection.
  • Fixed: checks on varchar and domain columns gave no keywords (#216). PostgreSQL writes the operand of these checks with a cast ((code)::text ~ '…', char_length((name)::text) >= 2), so they produced no pattern / minLength / maxLength in OpenAPI and no @constraint in the SDL. The cast is now read through.

Dev mode ​

  • Fixed: editing the config file did not change the contract or the limits (#214). In dev mode, an edit rebuilt the schema, but the served and exported OpenAPI document and the limits enforced on requests stayed on the startup configuration. The settings requests read (limits, errors, rbac, debug.statements, and everything that shapes the schema) now change together, in the contract and at runtime. A changed setting that only takes effect at startup (server, auth, limits.rate_limit, telemetry, …) is logged by name as needing a restart, instead of being ignored. The configuration guide lists both kinds. For code embedding graphpg as a library: ServerState's config, limits, errors and mask_errors are now read through ServerState::settings(), and ServerState::reload_with_config swaps them together with the schema.
  • Fixed: polling missed default and function-body changes (#212). Without event triggers (a role that cannot create them, as on managed databases), dev mode polls a catalog fingerprint, and that fingerprint missed a changed column default, a changed function argument default and a changed SQL-standard function body (RETURN …, BEGIN ATOMIC). The schema and the exported contracts stayed stale after such a migration.

Documentation and project ​

  • Docs: AI agents. A guide to graphpg for agents that use your data (vector, BM25 and hybrid search for retrieval; MCP tools through Apollo MCP Server; and the guardrails: the agent's own role, Row-Level Security, limits, an operation allow-list) and for agents that build applications (the generated, pinned GraphQL and OpenAPI contract, typed errors). The website's home page leads with both, the README shows the cover image, and the events guide has a diagram of delivery.
  • Documentation website (packages/website, pipelines): a VitePress site with a features page and the guides. The introduction and the changelog are copied from docs/, README.md and CHANGELOG.md at build time, with search and Mermaid diagrams, and pages.yml deploys it to GitHub Pages. Fixed: three rows of the PostgREST comparison table were split at an unescaped |.
  • Ready for a public repository: pull requests from forks always run CI on GitHub-hosted runners, never on the self-hosted ones CI_RUNS_ON names (pipelines). NOTICE carries the full MIT licence of the Graphile test material graphpg adapts.

1.1.0 - 2026-10-15 ​

The first published release (1.0.1 was never tagged nor published as an image), and the start of the stable API: from here, incompatible changes come only in a major version (versioning). Upgrading from a build of main before it: type names are now English singulars (people is a Person), updates and deletes take the key typed as queries take it (updateUser(id: UUID!), with updateUserByNodeId(nodeId: ID!) for global IDs), and requests failing on a lost connection or a changed schema are DATABASE_UNAVAILABLE / SCHEMA_CHANGED (503) instead of INTERNAL_SERVER_ERROR.

  • Foreign data errors (#153, #154): a remote violation of a foreign table's declared primary key is typed (ID_TAKEN, REST 409) like a local one, and listed in <Type>ErrorCode. A foreign server that is down answers SOURCE_UNAVAILABLE (503) and, with partial results, fails only the root fields reading from it.
  • Fixed: a unique index on the columns of a primary key declared in graphpg.yaml added a second lookup field (#157). Foreign tables no longer offer onConflict upserts, which postgres_fdw cannot run.
  • PostgreSQL 18 NOT ENFORCED constraints and NOT VALID checks: an unenforced check no longer produces an enum, an input rule or an error code. An unenforced foreign key stays a relation without a *_NOT_FOUND code. A NOT VALID value list no longer becomes an enum, since older rows may hold other values. Virtual generated columns are tested read-only.
  • Other databases and files through foreign data wrappers (guide): postgres_fdw and file_fdw foreign tables are tested end to end. With keys and relations declared in graphpg.yaml, they join local tables in one statement, both ways, with relation filters, aggregates, REST embedding and (for postgres_fdw) writes. The guide covers authorization without Row-Level Security, measured query plans, materialized views for files, transactions and errors.
  • Fixed (security): node(id:) and Federation _entities returned rows of a type whose reads are omitted (omit_operations: [read]), from a global ID any client can write.
  • Rate limits (limits.rate_limit, #17, off by default): a token bucket (GCRA) per client over GraphQL and REST, keyed by the verified token's sub (key_claim) or the IP address (X-Forwarded-For from trusted_proxies only), with anonymous and authenticated rates. Answers carry RateLimit-Limit / -Remaining / -Reset; a refused request is 429 with Retry-After and RATE_LIMITED. In memory, per instance.
  • Persisted queries (graphql.persisted_queries, #19, off by default): Apollo's automatic persisted queries (extensions.persistedQuery.sha256Hash), a manifest of operations (Apollo's format, or a map of ids to texts) named by documentId or hash and validated against the schema at startup, and allow_list to run only the manifest's operations (OPERATION_NOT_ALLOWED, /export included). Off, a request naming one is PERSISTED_QUERY_NOT_SUPPORTED.
  • CORS exposes graphpg's response headers (server.cors.expose_headers, default: every header graphpg sends): browser scripts could not read ETag, Link, Content-Range, X-Total-Count, X-WAL-Position or the new RateLimit-* / Retry-After. X-Min-WAL-Position is now an allowed request header by default. With rate limits on, the OpenAPI document gives each operation a 429 and each success the RateLimit-* headers.
  • Partial results (#21, #22): when a query's statement fails because of its data, its root fields are run apart in one read-only snapshot. The failing ones are null with an error carrying their path; the others keep their data. Successful queries still run as one statement. Nulls propagate through non-null root fields as the spec says, unless the request sends onError: "NULL" (graphql-spec RFC 1163); "HALT" fails the whole request. Timeouts and connection failures still fail the request, and mutations stay all or nothing.
  • GET /graphql for queries (a mutation is 405), so persisted queries can be cached by URL.
  • Observability (telemetry:, guide), for Prometheus, OpenTelemetry collectors and the Grafana LGTM stack. Metric names follow the OpenTelemetry semantic conventions, so scraped and pushed metrics read the same:
    • Metrics: Prometheus at /metrics (telemetry.metrics: on in dev, off in prod, optionally on a port of its own). They cover HTTP requests by route template, GraphQL operations by name and error code, database operations by type and SQLSTATE, connection pools, cache hits and planning time.
    • OTLP/HTTP export of traces, metrics and logs (telemetry.otlp: endpoint, headers, per-signal switches).
    • Traces: a server span per request with W3C traceparent propagation and PostgreSQL client spans; telemetry.traces.sample_ratio.
    • Logs: JSON log lines with request and trace ids (telemetry.logs).
    • Build: the otel feature is now on by default.
  • REST API at /rest, planned onto the same engine as GraphQL: typed filters, ordering, keyset pagination, embedding, single-row reads and writes with ETag / If-Match, nested writes, bulk creates, aggregates and grouped aggregates, functions (/rest/rpc/…), search methods and global search, Prefer: previous (RETURNING OLD), views. Errors are RFC 9457 application/problem+json with the typed constraint codes, masked in production.
  • Planned & estimated counts over REST: Prefer: count=planned uses PostgreSQL planner row estimates (EXPLAIN / reltuples); Prefer: count=estimated uses exact counting below limits.count_threshold (new, default 10,000) and planner estimates above it. Emits Preference-Applied: count=..., X-Total-Count, and X-Total-Count-Estimate: true headers.
  • Custom SQL problem responses: PostgREST PTxxx SQLSTATE codes (e.g. ERRCODE = 'PT402') and the new errors.map configuration map database exceptions and constraint names to custom RFC 9457 Problem Details responses (status, code, title, type, hint, extensions) and GraphQL extensions.code, unmasked in production for PTxxx, masked to the title for errors.map entries.
  • Table-per-type (CTI) hierarchies over REST & OpenAPI: Parent tables in CTI hierarchies automatically select __typename and subtype columns with type conditions on REST reads (subtype columns also work in filter and order, over GraphQL as well), and are represented in OpenAPI schemas as a oneOf union across subtypes with a discriminator on __typename.
  • Schema in the REST path (#122): /rest/{schema}/{resource} and /rest/{schema}/rpc/{function} (and everything below them) reach each of several database.schemas unambiguously, same-named tables and functions included; /rest/{resource} stays the default schema's shortcut. Only the schemas of the catalog have paths, and the OpenAPI document lists the concrete ones, with an operation id and components per schema.
  • Controls inside an embedding (#118): fields[posts], filter[posts][…], order[posts], first[posts] and offset[posts] for each included relation (also in a single-row read and a search body), inner= / anti= for inner joins and anti-joins, order=author.username for a to-one relation's column, and computed relationships (mounted functions) selectable through include and fields. Listed per relation in the OpenAPI document.
  • Collection writes over REST (#116): PATCH and DELETE on /rest/<table> by the list's filter (refused without one unless unfiltered=true, bounded by limits.bulk_rows and rolled back above it, X-Affected-Count), bulk upsert with PUT /rest/<table> (conflict, resolution=merge|ignore, update), missing=default|null for bulk writes, application/merge-patch+json and, for one row, RFC 6902 application/json-patch+json. limits.bulk_rows now also bounds GraphQL update<Types>(where:) and delete<Types>(where:). Fixed: a GraphQL update or delete by filter selecting no rows in its payload failed with a parameter error.
  • Change events as OpenAPI 3.1 webhooks (#100): entities.<table>.events (operations, description) adds one webhook per change (orders.insert, .update, .delete) typed from the table's row schema (old and new). Delivery belongs to the event-trigger epic.
  • REST benchmark against PostgREST (#63): benchmarks/rest (k6 script, schema and data, compare.sh) runs the same lists, embeddings, paginations and writes against graphpg and PostgREST on one database; results and caveats in docs/comparison.md.
  • GraphQL and OpenAPI parity (docs/graphql-openapi-parity.md): what each API knows about the database now exists in both.
    • REST gets what GraphQL had: hierarchies (include=ancestors, depth[…]), exclusive arcs (include=target), temporal keys (asOf, asOf[…]), computed fields with arguments (args[field][name]), per-row cursors, window values and running sums (cursors, window, running, partition), aggregate sets with filters of their own, access by a unique key (?by=), full-text highlight, JSON path[column], GET /rest/node/{id}, NDJSON lists, X-Debug-SQL / X-Debug-Explain in dev, camel-case order names (messagesCount).
    • The OpenAPI document states what the API accepts: the filter operators of spatial, ltree, full-text and multirange columns, every order option, <table>ErrorCode / <table>Problem for 400, 409 and 422 answers, the relations a row carries when embedded, /export and /import/{type}.
    • GraphQL gets what REST had: @constraint(…) (the CHECK and varchar(n) keywords of the document) on fields and inputs, totalCount(estimate: true, exactBelow: n), and limits.require_filter with unfiltered: Boolean on update<Types> / delete<Types>.
  • OpenAPI 3.1 document generated from the catalog and configuration: typed parameters, nested-write bodies (the GraphQL input types), the configured limits; JSON or YAML; served at openapi.path and shown by the bundled Scalar reference (which now follows openapi.path and works without openapi.spec).
  • Safety limits (limits:) shared by GraphQL and REST and enforced on the plan: page size (default and maximum), depth, cost, bulk rows, aggregate groups, request body size, and statement_timeout. GraphQL previously had none: first above limits.page_size.max is now an error and plain lists are cut at that size.
  • Pinned schemas: graphpg schema export [--check], graphpg export-openapi, export.openapi, and export.pin (fail in prod, warn in dev) compare the exported GraphQL SDL, subgraph and OpenAPI files with the database at startup.
  • Fixed: REST rows returned composite, range and multirange columns as empty objects; the OpenAPI document described bigint / numeric as numbers (they are strings), json as objects, bytea as binary and enums by their database labels.
  • CORS is configurable (server.cors: origins, methods, headers, credentials, max_age). It was permissive everywhere; it is now open in dev and off in prod until origins are listed.
  • Error detail (errors.detail: masked in prod, full in dev) for GraphQL and REST; GraphQL database errors were shown as they came. A privilege or Row-Level Security refusal is now PERMISSION_DENIED (REST: 403) in prod.
  • Introspection switch (graphql.introspection, off in prod) and the SDL over HTTP (graphql.sdl, /schema.graphql, and /subgraph.graphql with federation).
  • REST media types: CSV lists (Accept: text/csv, 406 otherwise), CSV bulk creates, multipart/form-data writes, the bytes of a bytea column at /rest/<table>/<key>/<column>, OPTIONS with Allow / Accept-Post / Accept-Patch; HEAD and OPTIONS are in the OpenAPI document.
  • OpenAPI inference: CHECK constraints as JSON Schema keywords (bounds, multipleOf, length, pattern, item counts), Single Table Inheritance as oneOf with a discriminator, and links from foreign keys on the single-row GET.
  • REST ranges: Range: items=A-B on lists answers 206 with Content-Range (the total with Prefer: count=exact) or 416; every list sends Accept-Ranges: items.
  • REST filters: regex / iregex, isDistinctFrom, …Any / …All of a list (also in GraphQL filters), or / and / not groups in the query string, array and JSON operators in GET, and range filter operators (contains, strictlyLeftOf, strictlyRightOf, adjacentTo, overlaps, containedBy) accepting [lower,upper) string bounds and JSON input objects.
  • HTTP semantics & tracing: X-Request-Id middleware echoes incoming request IDs or generates a UUIDv4 on all responses and error payloads; Server-Timing latency header emitted in dev mode; conditional reads on GET and HEAD /rest/{resource}/{key} with If-None-Match answering 304 Not Modified with no body.
  • Operational probes: /live (liveness probe answering 200 OK) and /ready (readiness probe validating database connectivity via SELECT 1, returning 503 if unreachable).
  • RPC improvements: functions with a single json or jsonb parameter accept raw JSON bodies directly without the {"args": …} wrapper.
  • Docs: a comparison with PostgREST, the REST API guide, safety limits and pinned schemas in the configuration guide.
  • Schema reload without a restart (reload:, on by default): NOTIFY graphpg_reload (from a migration) or SIGHUP reads the database again and swaps the schema in atomically. A reload that fails a startup check (pinned schemas with export.pin: fail, the persisted query manifest in prod) is refused, and the previous schema keeps serving.
  • NULLS FIRST / NULLS LAST ordering: <NAME>_ASC_NULLS_FIRST and <NAME>_DESC_NULLS_LAST in every <Type>OrderBy for options that can be NULL, and order=-rank:nullslast over REST. Keyset pagination stays exact in every placement, forward and backward. The new enum values change the exported SDL: re-export pinned schemas (graphpg schema export).
  • Startup advice for foreign tables (#150): missing keys, postgres_fdw without use_remote_estimate, never-analyzed foreign tables, to-many relations into them (one remote query or file read per parent row), root fields while authenticated (no Row-Level Security), and roles without a user mapping. Also in graphpg advise.
  • Batched relations into foreign tables (#155, batch: true on a virtual foreign key): a list relation reads a page of parents' children in one remote query (= ANY their keys, shipped by postgres_fdw) instead of one per parent. Measured 2.8× faster on loopback, 100 remote queries down to 1; the per-parent limit is applied locally, so it suits parents with a bounded number of children.
  • Event triggers (events:, #47–#53, guide): signed webhooks when rows change and on cron schedules. Changes are captured by graphpg-managed triggers in the change's own transaction (statement-level with transition tables for inserts and deletes; row-level, only on a real change, for updates), and payloads are shaped as REST rows. Delivery is at least once, claimed with leases (SKIP LOCKED) so any number of instances share it, woken by NOTIFY, retried with backoff and jitter, then dead-lettered. Requests are signed per Standard Webhooks. Cron slots are enqueued once across instances, and one-off events come from SQL (graphpg_events.schedule()). The webhooks are in the OpenAPI document; graphpg events sql prints the DDL for install: manual.
  • Test image with extensions (#169): docker/test-pg builds PostgreSQL 18 with TimescaleDB (full edition), pgmq, pg_jsonschema, pgvector, pgvectorscale, VectorChord, PGroonga, PostGIS, pg_cron, hypopg and pg_partman, published as ghcr.io/mihailtd/graphpg-test-pg:18; docker compose up uses it, so extension integrations are tested against the real thing.
  • Extension integrations (#169, guide): graphpg detects the extensions the exposed schemas use (TimescaleDB hypertables, pg_jsonschema checks, diskann / VectorChord / PGroonga indexes, postgres_fdw foreign tables; pg_stat_statements when installed), reports them at startup and in graphpg advise, and lets each be switched off (extensions.integrations.<name>: off). The integrations themselves follow (#170–#176). Message queues (pgmq) are served through functions of your own, not directly: see the guide's recipe.
  • TimescaleDB integration (#170): estimated totals on hypertables use approximate_row_count() (they were an exact count(*) over every chunk, since a hypertable's own statistics are empty); hypertables and continuous aggregates are described as such; advice for a hypertable without a key names the time column its key must include.
  • pg_jsonschema integration (#171): a json / jsonb column with a CHECK (jsonb_matches_schema('<schema>', column)) has that JSON Schema as its type in the OpenAPI document (nullable columns allow null), so typed REST clients get real types; violations are <COLUMN>_INVALID.
  • pgvectorscale and VectorChord integrations (#173): diskann, vchordrq and vchordg indexes on vector columns give semantic search fields (GraphQL <list>SemanticSearch, REST search/semantic) like pgvector's hnsw / ivfflat, using the index's distance. extensions.integrations.pgvectorscale / vectorchord: off ignores them.
  • PGroonga integration (#176): PGroonga full-text indexes (on text, varchar, text[] or ARRAY[col, ...]) give full-text search in any language, CJK included: <list>FullTextSearch, REST search/full-text and globalSearch, with Groonga's query syntax, pgroonga_score ranking and highlights. extensions.integrations.pgroonga: off ignores them.
  • graphpg advise --statements (#174): graphpg's most expensive operations (GraphQL operations, REST routes) from pg_stat_statements, grouped by graphpg's sqlcommenter tags, with the indexes their plans lack: each statement planned with EXPLAIN (GENERIC_PLAN), each candidate index checked with a hypopg hypothetical index when installed (suggested only if it lowers the estimated cost, shown before and after).
  • Docs: the foreign data guide's pg_duckdb advice is corrected: files read with read_parquet must be loaded into a materialized view (a plain view over them fails, since DuckDB cannot parse graphpg's SQL/JSON statements), and a crash seen with pgrx extensions preloaded alongside is noted.
  • Extensions in their own schema work: graphpg appends the schemas of installed extensions to each connection's search_path when it opens the connection (Supabase keeps them in extensions), so fuzzy, semantic, spatial and other extension-backed fields no longer fail when that schema is off the role's path. The configured path keeps precedence, and requests pay nothing for it.
  • Keys of postgres_fdw foreign tables are imported (#152): the remote table's primary key, unique constraints and foreign keys (between tables reached through foreign tables), read from the remote catalog over the same server and user mapping, through temporary foreign tables in a rolled-back transaction. Keys in graphpg.yaml win; extensions.integrations.postgres_fdw: off turns it off.
  • CI tests Parquet through pg_duckdb (#156): a materialized view over read_parquet served, joined to a local table and aggregated, and a plain view over it failing as the guide says, against the pgduckdb/pgduckdb:18 image (a second service of the test job).
  • Faster distinct counts: a root connection's distinctCount (GraphQL aggregates, REST /aggregate?distinctCount=) counts the distinct values with a parallel hash aggregate instead of count(DISTINCT ...), which PostgreSQL sorts in one process: 4x faster with totalCount over 10M rows. Grouped and nested distinct counts are unchanged.
  • Fix: no database.schemas configured. CLI commands (export-*, advise, events sql) panicked, and a dev-mode reload inspected no schema at all (the API lost every table on the first file change). They now use the documented default, app_public, as the server does at startup.
  • GET /debug/statements in dev mode: the graphpg advise --statements report (the most expensive operations so far, from pg_stat_statements, with the indexes they lack, checked with hypopg) as JSON while you develop. dev.debug.statements, on in dev mode; rejected outside the dev: block, so production never serves it.
  • Slow operations logged in dev mode: with pg_stat_statements installed, graphpg warns once per operation that averages 100 ms or more (checked every 30 seconds from pg_stat_statements alone), pointing to /debug/statements for the index it lacks. Part of dev.debug.statements.
  • Subscriptions (live queries) (#25, #26, #27, guide), opt-in with subscriptions.enabled: a Subscription type with the Query type's root fields, whose result is sent again whenever it changes. Every subscriptions.interval (1 s) each instance runs its subscriptions again, batched: subscriptions of one compiled query and the same claims are one statement (the query LATERAL-joined to their parameters, returning only results whose hash changed), every user's claims applied and reset in their own savepoint, and claims left out of the grouping when the query reads only tables without Row-Level Security. Runs read from replicas when there are any. Transports: WebSocket (graphql-transport-ws, as Apollo Router uses with subgraphs), GraphQL over SSE and multipart HTTP; GraphiQL runs subscriptions over SSE. The token is checked when subscribing and the subscription ends when it expires. Limits per instance and per client; metrics graphpg_subscription_active, graphpg_subscription_round_duration_seconds, graphpg_subscription_results_total. Nothing is installed in the database.
  • Fixes found by exploring a large schema (FHIR R5, 177 tables) and fuzzing its inputs, each with a regression test:
    • Many-to-many relations listed a row once per junction row linking it (a person with two roles in a team twice), so totalCount and the pages disagreed and keyset pagination skipped rows. They are now a set (EXISTS on the junction), in lists, connections and relation filters (some, count).
    • min / max of uuid, boolean, macaddr, macaddr8, ltree and citext columns failed (PostgreSQL has no such aggregates); they are computed in each type's own order. min / max of a range column returned {} (a scalar computed for an object type): they are the first and last range in the range type's order, with their bounds.
    • A connection selecting only totalCount (or aggregates) with after / before failed (the cursor was bound for a page not read).
    • Input the database cannot take is refused as input (BAD_USER_INPUT, REST 400) instead of failing with a server error: cursors the connection did not issue, enum values passed in variables, text with a NUL character, invalid JSON paths and regular expressions, and LIKE patterns ending with the escape character (which failed only when a row matched up to it).
    • A JSON string compared with a json / jsonb column (equalTo: "draft") was bound as raw text (an error, or no match); json columns now compare as jsonb, whitespace aside.
    • A table named like one of graphpg's own types (subscriptions, page_info) clashed with it: its type takes its schema's name in front (PublicSubscription), as tables clashing across schemas do.
    • Startup and schema reloads on large schemas: 22 s to 0.7 s, the @constraint and JSON Schema keywords of every column no longer compiling six regular expressions per column (SDL 3.1 s to 15 ms, OpenAPI 5.7 s to 0.3 s on 177 tables); the OpenAPI document is built when first served, not three times at startup; an unchanged pinned OpenAPI file is compared as text, without parsing it.
    • GET /rest answered hello world; it lists the resources and functions, as links.
    • Dev mode: X-Debug-SQL / X-Debug-Explain headers are capped at 8 KiB (larger ones broke Node clients and proxies), the full value served at /debug/sql/{id}; without a superuser, catalog polling now sees every change the API is built from (it only saw new relations and functions, and nothing when database.schemas was not set); the watcher logs through tracing like the rest of the server.
  • A row with many relations could exhaust the database's memory: relations were all LEFT JOIN LATERALs, and PostgreSQL's planner searched their join orders. One request selecting the 193 relations of a FHIR Patient took 2.1 GB and 465 ms to plan (20 at once: the database was killed for lack of memory). Past eight relations in a row they are scalar subqueries: 25 MB and 15 ms, the same throughput on typical queries.
  • database.statement_cache: prepared statements per connection (512 by default, 0 for none), each holding its plan in the connection's backend; lower it for very many distinct operations on a wide schema.
  • Exploratory end-to-end tests (e2e/src/explore.ts, pnpm --filter @graphpg/e2e explore <url>): the schema's own offering crawled and fuzzed; the end-to-end suite runs them on the blog schema, and subscriptions end to end over SSE and WebSocket.
  • Renamed types are warned about: when a table, composite type, enum or domain cannot take the type name derived from it (it is one of graphpg's own, or another schema's table has it), startup and graphpg advise say which name graphpg chose and the type_name setting that chooses another. A type_name naming one of graphpg's own types is refused.
  • Exploratory load tests (k6) in benchmarks/explore: crawl.js, fuzz.js, writes.js and soak.js generate their requests from any server's schema by introspection, and fail on any request the engine failed on.
  • Breaking: type names are English singulars of their tables: people is a Person (person(id:), was peopleById), subscription_statuses a SubscriptionStatus (was SubscriptionStatuse), episodes_of_care an EpisodeOfCare (was EpisodesOfCare), with irregular plurals, -ses/-xes/-ches/-shes/-ies, and nouns that are their own plural. Names of many rows are the type's English plural (createPeople, createEpisodesOfCare); a noun that is its own plural (evidence) takes its table's name (createEvidences) or List, where its bulk mutations used to be dropped.
  • Breaking: updates and deletes take the row's key typed as queries take it: updatePost(id: Int!), updateUser(id: UUID!) (was id: ID! for every table with a single-column key). Global IDs have their own fields: update<Type>ByNodeId(nodeId: ID!) and delete<Type>ByNodeId(nodeId: ID!), which refuse the ID of another type (the global ID used to be decoded whatever its type).
  • Fixes found exploring auth claims, concurrent DDL and failover, each with a regression test:
    • Global IDs are typed everywhere: a key argument, a foreign-key input or filter, and a Federation _entities representation refuse (or, for _entities, find nothing for) the global ID of another type, which used to be decoded whatever its type (a User's ID resolved the Forum with the same key).
    • A token with a NUL character in a claim failed every request with a 500 (PostgreSQL settings cannot hold one): it is refused (401). A claim cannot replace claims_setting or a mapped setting through session_variables.prefix.
    • GET /rest was not authenticated: it listed resources for invalid tokens, and without a token when anonymous requests are refused.
    • A connection whose request panicked mid-transaction went back to the pool inside that transaction, with the request's role and claims: it is closed instead.
    • Requests caught by a schema change (a column renamed or dropped while they ran, or before the server read the schema again) failed as INTERNAL_SERVER_ERROR: they are SCHEMA_CHANGED (503, retryable), and the server reads the database again by itself, also without DDL watching. Under continuous DDL, the k6 soak went from 111 113 engine failures to none.
    • Requests whose connection the database ended (a restart, a failover) failed as INTERNAL_SERVER_ERROR: they are DATABASE_UNAVAILABLE (503, retryable), as GraphQL requests that cannot get a connection now say too.
    • Reading the catalog while DDL runs could panic (a column dropped meanwhile has null privileges).
    • Dev mode's DDL watcher, once its connection failed (a database restart), reloaded the schema about four times a second forever; it now polls, reconnects, and stays quiet.
  • benchmarks/explore/auth.js: k6 requests with valid tokens carrying hostile claims and invalid tokens of every kind; neither may get a 5xx, nor the other's answer.

1.0.1 - 2026-10-04 ​

First public release.

  • GraphQL generated from PostgreSQL catalogs: relations, Relay connections with keyset pagination, filters, aggregates, search, nested writes and typed constraint errors.
  • OAuth 2.0 bearer tokens with Row-Level Security, schema pruning from GRANTs, Apollo Federation v2 subgraph mode, @defer / @stream.
  • Container image on the GitHub Container Registry: a static binary on distroless/static, running as a non-root user, signed with Sigstore. Tagged X.Y.Z, X.Y, X, latest for releases, and sha-<short hash> and edge for every commit on main.
  • Graceful shutdown on SIGTERM.