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/jsonbcolumn checked byjsonb_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(ordefinitions) 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
$schemais rewritten where JSON Schema 2020-12 (the dialect of OpenAPI 3.1) reads it differently: tupleitems,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 callANDother conditions, type the column. - Errors that say why: the error for a rejected value carries
validationErrors(in GraphQLextensionsand in the REST problem document), with each failed keyword and a JSON Pointer to where in the value it failed. In a bulk write,fieldsnames 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
CHECKrejects answered500. This affected any domain, for exampleCREATE 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(REST422) 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 bygraphpg schema exportand checked byexport.pindescribes bodies with the GraphQL input types, which typed apg_jsonschemacolumn as any JSON and dropped everyCHECKkeyword (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 nodefault, 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 ajsondefault as the value. A default PostgreSQL computes (now(),uuidv7(),nextval(…), any expression) has nodefault; the database still fills it in. Nothing is evaluated during introspection. - Fixed: checks on
varcharand 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 nopattern/minLength/maxLengthin OpenAPI and no@constraintin 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'sconfig,limits,errorsandmask_errorsare now read throughServerState::settings(), andServerState::reload_with_configswaps 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 fromdocs/,README.mdandCHANGELOG.mdat build time, with search and Mermaid diagrams, andpages.ymldeploys 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_ONnames (pipelines).NOTICEcarries 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, REST409) like a local one, and listed in<Type>ErrorCode. A foreign server that is down answersSOURCE_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.yamladded a second lookup field (#157). Foreign tables no longer offeronConflictupserts, whichpostgres_fdwcannot run. - PostgreSQL 18
NOT ENFORCEDconstraints andNOT VALIDchecks: 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_FOUNDcode. ANOT VALIDvalue 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_fdwandfile_fdwforeign tables are tested end to end. With keys and relations declared ingraphpg.yaml, they join local tables in one statement, both ways, with relation filters, aggregates, REST embedding and (forpostgres_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_entitiesreturned 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'ssub(key_claim) or the IP address (X-Forwarded-Forfromtrusted_proxiesonly), withanonymousandauthenticatedrates. Answers carryRateLimit-Limit/-Remaining/-Reset; a refused request is429withRetry-AfterandRATE_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 bydocumentIdor hash and validated against the schema at startup, andallow_listto run only the manifest's operations (OPERATION_NOT_ALLOWED,/exportincluded). Off, a request naming one isPERSISTED_QUERY_NOT_SUPPORTED. - CORS exposes graphpg's response headers (
server.cors.expose_headers, default: every header graphpg sends): browser scripts could not readETag,Link,Content-Range,X-Total-Count,X-WAL-Positionor the newRateLimit-*/Retry-After.X-Min-WAL-Positionis now an allowed request header by default. With rate limits on, the OpenAPI document gives each operation a429and each success theRateLimit-*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
nullwith an error carrying theirpath; 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 sendsonError: "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 /graphqlfor queries (a mutation is405), 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
traceparentpropagation and PostgreSQL client spans;telemetry.traces.sample_ratio. - Logs: JSON log lines with request and trace ids (
telemetry.logs). - Build: the
otelfeature is now on by default.
- Metrics: Prometheus at
- REST API at
/rest, planned onto the same engine as GraphQL: typed filters, ordering, keyset pagination, embedding, single-row reads and writes withETag/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 9457application/problem+jsonwith the typed constraint codes, masked in production. - Planned & estimated counts over REST:
Prefer: count=planneduses PostgreSQL planner row estimates (EXPLAIN/reltuples);Prefer: count=estimateduses exact counting belowlimits.count_threshold(new, default 10,000) and planner estimates above it. EmitsPreference-Applied: count=...,X-Total-Count, andX-Total-Count-Estimate: trueheaders. - Custom SQL problem responses: PostgREST
PTxxxSQLSTATE codes (e.g.ERRCODE = 'PT402') and the newerrors.mapconfiguration map database exceptions and constraint names to custom RFC 9457 Problem Details responses (status, code, title, type, hint, extensions) and GraphQLextensions.code, unmasked in production forPTxxx, masked to the title forerrors.mapentries. - Table-per-type (CTI) hierarchies over REST & OpenAPI: Parent tables in CTI hierarchies automatically select
__typenameand subtype columns with type conditions on REST reads (subtype columns also work infilterandorder, over GraphQL as well), and are represented in OpenAPI schemas as aoneOfunion 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 severaldatabase.schemasunambiguously, 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]andoffset[posts]for eachincluded relation (also in a single-row read and a search body),inner=/anti=for inner joins and anti-joins,order=author.usernamefor a to-one relation's column, and computed relationships (mounted functions) selectable throughincludeandfields. Listed per relation in the OpenAPI document. - Collection writes over REST (#116):
PATCHandDELETEon/rest/<table>by the list'sfilter(refused without one unlessunfiltered=true, bounded bylimits.bulk_rowsand rolled back above it,X-Affected-Count), bulk upsert withPUT /rest/<table>(conflict,resolution=merge|ignore,update),missing=default|nullfor bulk writes,application/merge-patch+jsonand, for one row, RFC 6902application/json-patch+json.limits.bulk_rowsnow also bounds GraphQLupdate<Types>(where:)anddelete<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 (oldandnew). 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 indocs/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), aggregatesetswith filters of their own, access by a unique key (?by=), full-texthighlight, JSONpath[column],GET /rest/node/{id}, NDJSON lists,X-Debug-SQL/X-Debug-Explainin dev, camel-caseordernames (messagesCount). - The OpenAPI document states what the API accepts: the filter operators of spatial,
ltree, full-text and multirange columns, everyorderoption,<table>ErrorCode/<table>Problemfor 400, 409 and 422 answers, the relations a row carries when embedded,/exportand/import/{type}. - GraphQL gets what REST had:
@constraint(…)(theCHECKandvarchar(n)keywords of the document) on fields and inputs,totalCount(estimate: true, exactBelow: n), andlimits.require_filterwithunfiltered: Booleanonupdate<Types>/delete<Types>.
- REST gets what GraphQL had: hierarchies (
- 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.pathand shown by the bundled Scalar reference (which now followsopenapi.pathand works withoutopenapi.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, andstatement_timeout. GraphQL previously had none:firstabovelimits.page_size.maxis now an error and plain lists are cut at that size. - Pinned schemas:
graphpg schema export [--check],graphpg export-openapi,export.openapi, andexport.pin(failin prod,warnin 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/numericas numbers (they are strings),jsonas objects,byteaas 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:maskedin prod,fullin dev) for GraphQL and REST; GraphQL database errors were shown as they came. A privilege or Row-Level Security refusal is nowPERMISSION_DENIED(REST: 403) in prod. - Introspection switch (
graphql.introspection, off in prod) and the SDL over HTTP (graphql.sdl,/schema.graphql, and/subgraph.graphqlwith federation). - REST media types: CSV lists (
Accept: text/csv,406otherwise), CSV bulk creates,multipart/form-datawrites, the bytes of abyteacolumn at/rest/<table>/<key>/<column>,OPTIONSwithAllow/Accept-Post/Accept-Patch;HEADandOPTIONSare in the OpenAPI document. - OpenAPI inference:
CHECKconstraints as JSON Schema keywords (bounds,multipleOf, length,pattern, item counts), Single Table Inheritance asoneOfwith adiscriminator, andlinksfrom foreign keys on the single-rowGET. - REST ranges:
Range: items=A-Bon lists answers206withContent-Range(the total withPrefer: count=exact) or416; every list sendsAccept-Ranges: items. - REST filters:
regex/iregex,isDistinctFrom,…Any/…Allof a list (also in GraphQL filters),or/and/notgroups in the query string, array and JSON operators inGET, and range filter operators (contains,strictlyLeftOf,strictlyRightOf,adjacentTo,overlaps,containedBy) accepting[lower,upper)string bounds and JSON input objects. - HTTP semantics & tracing:
X-Request-Idmiddleware echoes incoming request IDs or generates a UUIDv4 on all responses and error payloads;Server-Timinglatency header emitted in dev mode; conditional reads onGETandHEAD /rest/{resource}/{key}withIf-None-Matchanswering304 Not Modifiedwith no body. - Operational probes:
/live(liveness probe answering 200 OK) and/ready(readiness probe validating database connectivity viaSELECT 1, returning 503 if unreachable). - RPC improvements: functions with a single
jsonorjsonbparameter 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) orSIGHUPreads the database again and swaps the schema in atomically. A reload that fails a startup check (pinned schemas withexport.pin: fail, the persisted query manifest in prod) is refused, and the previous schema keeps serving. NULLS FIRST/NULLS LASTordering:<NAME>_ASC_NULLS_FIRSTand<NAME>_DESC_NULLS_LASTin every<Type>OrderByfor options that can be NULL, andorder=-rank:nullslastover 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_fdwwithoutuse_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 ingraphpg advise. - Batched relations into foreign tables (#155,
batch: trueon a virtual foreign key): a list relation reads a page of parents' children in one remote query (= ANYtheir keys, shipped bypostgres_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 byNOTIFY, 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 sqlprints the DDL forinstall: manual. - Test image with extensions (#169):
docker/test-pgbuilds PostgreSQL 18 with TimescaleDB (full edition), pgmq, pg_jsonschema, pgvector, pgvectorscale, VectorChord, PGroonga, PostGIS, pg_cron, hypopg and pg_partman, published asghcr.io/mihailtd/graphpg-test-pg:18;docker compose upuses 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 ingraphpg 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 exactcount(*)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/jsonbcolumn with aCHECK (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,vchordrqandvchordgindexes onvectorcolumns give semantic search fields (GraphQL<list>SemanticSearch, RESTsearch/semantic) like pgvector'shnsw/ivfflat, using the index's distance.extensions.integrations.pgvectorscale/vectorchord: offignores them. - PGroonga integration (#176): PGroonga full-text indexes (on
text,varchar,text[]orARRAY[col, ...]) give full-text search in any language, CJK included:<list>FullTextSearch, RESTsearch/full-textandglobalSearch, with Groonga's query syntax,pgroonga_scoreranking and highlights.extensions.integrations.pgroonga: offignores them. graphpg advise --statements(#174): graphpg's most expensive operations (GraphQL operations, REST routes) frompg_stat_statements, grouped by graphpg's sqlcommenter tags, with the indexes their plans lack: each statement planned withEXPLAIN (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_duckdbadvice is corrected: files read withread_parquetmust 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_pathwhen it opens the connection (Supabase keeps them inextensions), 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_fdwforeign 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 ingraphpg.yamlwin;extensions.integrations.postgres_fdw: offturns it off. - CI tests Parquet through pg_duckdb (#156): a materialized view over
read_parquetserved, joined to a local table and aggregated, and a plain view over it failing as the guide says, against thepgduckdb/pgduckdb:18image (a second service of the test job). - Faster distinct counts: a root connection's
distinctCount(GraphQLaggregates, REST/aggregate?distinctCount=) counts the distinct values with a parallel hash aggregate instead ofcount(DISTINCT ...), which PostgreSQL sorts in one process: 4x faster withtotalCountover 10M rows. Grouped and nested distinct counts are unchanged. - Fix: no
database.schemasconfigured. 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/statementsin dev mode: thegraphpg advise --statementsreport (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 thedev: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/statementsfor the index it lacks. Part ofdev.debug.statements. - Subscriptions (live queries) (#25, #26, #27, guide), opt-in with
subscriptions.enabled: aSubscriptiontype with theQuerytype's root fields, whose result is sent again whenever it changes. Everysubscriptions.interval(1 s) each instance runs its subscriptions again, batched: subscriptions of one compiled query and the same claims are one statement (the queryLATERAL-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; metricsgraphpg_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
totalCountand the pages disagreed and keyset pagination skipped rows. They are now a set (EXISTSon the junction), in lists, connections and relation filters (some,count). min/maxofuuid,boolean,macaddr,macaddr8,ltreeandcitextcolumns failed (PostgreSQL has no such aggregates); they are computed in each type's own order.min/maxof 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) withafter/beforefailed (the cursor was bound for a page not read). - Input the database cannot take is refused as input (
BAD_USER_INPUT, REST400) 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, andLIKEpatterns ending with the escape character (which failed only when a row matched up to it). - A JSON string compared with a
json/jsonbcolumn (equalTo: "draft") was bound as raw text (an error, or no match);jsoncolumns now compare asjsonb, 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
@constraintand 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 /restansweredhello world; it lists the resources and functions, as links.- Dev mode:
X-Debug-SQL/X-Debug-Explainheaders 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 whendatabase.schemaswas not set); the watcher logs throughtracinglike the rest of the server.
- Many-to-many relations listed a row once per junction row linking it (a person with two roles in a team twice), so
- 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 FHIRPatienttook 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 advisesay which name graphpg chose and thetype_namesetting that chooses another. Atype_namenaming one of graphpg's own types is refused. - Exploratory load tests (k6) in
benchmarks/explore:crawl.js,fuzz.js,writes.jsandsoak.jsgenerate 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:
peopleis aPerson(person(id:), waspeopleById),subscription_statusesaSubscriptionStatus(wasSubscriptionStatuse),episodes_of_careanEpisodeOfCare(wasEpisodesOfCare), 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) orList, 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!)(wasid: ID!for every table with a single-column key). Global IDs have their own fields:update<Type>ByNodeId(nodeId: ID!)anddelete<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
_entitiesrepresentation refuse (or, for_entities, find nothing for) the global ID of another type, which used to be decoded whatever its type (aUser's ID resolved theForumwith 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 replaceclaims_settingor a mapped setting throughsession_variables.prefix. GET /restwas 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 areSCHEMA_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 areDATABASE_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.
- Global IDs are typed everywhere: a key argument, a foreign-key input or filter, and a Federation
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. TaggedX.Y.Z,X.Y,X,latestfor releases, andsha-<short hash>andedgefor every commit onmain. - Graceful shutdown on SIGTERM.