@burojs/hasura
v0.5.0
Published
Buro: Hasura GraphQL data provider and live (subscription) provider
Readme
@burojs/hasura
A Hasura GraphQL data provider and live (subscription) provider for buro.
Install
pnpm add @burojs/hasura @burojs/coreUsage
import { createHasuraDataProvider } from '@burojs/hasura';
const dataProvider = createHasuraDataProvider({
// Where to POST. Same-origin path, path-prefixed path, or absolute URL.
// Defaults to '/gql/v1/graphql'.
endpoint: '/gql/v1/graphql',
getToken: async () => session.jwt,
// X-Hasura-Role for resources that don't declare their own. Omit for none.
defaultRole: 'company-admin',
resources: {
orders: {
table: 'orders',
selectFields: ['id', 'code', 'status', 'created_at'],
pkType: 'uuid',
live: true,
},
},
});createHasuraDataProvider(config)
A DataProvider over Hasura's GraphQL API: list / one / many / count
queries, createOne / updateOne / deleteOne mutations, an opt-in
custom-operation registry, and a bundled live provider. Offset pagination only
— cursor pagination is refused with a capability error.
Everything the provider does is driven by the per-resource ResourceMapping
registry rather than by branching on resource names:
| Mapping field | What it declares |
|---|---|
| table, selectFields | the Hasura table and the columns to select |
| pkField, pkType | primary-key column name and its GraphQL scalar (bigint | uuid | Int | String) |
| byPkRoot | the <table>_by_pk root name, or false when the role exposes only the collection root |
| aggregate, noAggregate | whether a count root exists, and whether to pay for a count on every page |
| role | per-resource X-Hasura-Role (null suppresses the header) |
| scopeFields | which keys from getScope() this table actually has columns for |
| fixedFilter | an always-on where fragment — several resource names over one table, each a different slice |
| int64Fields | columns Hasura serialises as JSON strings (ClickHouse int64), whose filter values must be stringified |
| jsonbSpill | route a flat form record into/out of JSONB head / meta columns |
| dataBody | route a heavy array payload into/out of a 1:1 sibling table |
| live | opt this resource into subscription-driven invalidation |
Errors Hasura sends with HTTP 200
Hasura answers every GraphQL-level failure with HTTP 200, so the provider
classifies the envelope by extensions.code: access-denied and
permission-error reject as { kind: 'forbidden', code }, invalid-jwt as
{ kind: 'auth', code: 'invalid-jwt' }, and every other code as
{ kind: 'protocol', code: 'graphql-error' }. When the app passes an
authProvider to <BuroProvider>, an auth rejection reaches its onError,
which decides whether to refresh; on refreshed the runtime replays a read once
(a mutation once when it is replay-safe). config.recoverAuth remains the hook
for an HTTP 401, which a proxy or gateway in front of Hasura answers; it is
not consulted for the envelope. A mutation whose request was sent and got no
answer at all rejects as { kind: 'sent', code: 'hasura-request-sent',
retryable: false }: it may have been applied. Pinned by
tests/fixture-fidelity.test.ts and tests/error-kinds.test.ts.
Optimistic concurrency is available per call: pass
providerOptions.ifUpdatedAt on an update and the mutation switches to a
where-guarded form, rejecting with { kind: 'conflict', code:
'stale-record' } when the row has moved on. Omit it (or pass null) and
nothing changes.
createHasuraLiveProvider(config)
The subscription half, already wired in by createHasuraDataProvider. Exported
separately for callers that want it on its own. The socket carries only a
change SIGNAL — rows always come back through the regular descriptor query, so
mapping and pagination stay single-sourced in the data provider.
hasuraWsUrl(endpoint) (also exported) is the http(s) → ws(s) conversion. Two
things a caller has to know about it:
- Its argument is the full endpoint, the same string you pass
createHasuraDataProvider— not a path prefix. It appends nothing. (The two applications this package was distilled from had a same-named, same-signature function that took a PREFIX and appended/gql/v1/graphqlitself; copying one of those call sites here yields a socket URL that addresses nothing, and a socket that never connects does not throw. Pinned bytests/live-provider.test.ts.) - The conversion needs a
window. With none (SSR, or a non-browser import) the endpoint is returned unchanged — an absolutehttps://…endpoint comes back ashttps://…, notwss://…. A relative endpoint has no host to resolve against there, so this is a real boundary, not just a scheme bug.
Provenance
This package was distilled from two independently-evolved copies of the same provider that lived in two applications. The copies had drifted in exactly seven places; each resolution is documented in a comment next to the code it affects, saying which side it came from and whether it was a feature, a bug fix, or drift. Two defects that neither copy had fixed at the time were closed here, each with a test that reddens without the fix; both were subsequently backported into both applications, so the in-code notes describe the state this package was distilled FROM, not the state of the repository today.
Neither application is migrated onto this package and neither has been removed yet, so at present the repository holds three copies of this provider rather than one. That is deliberate — the applications are leaving, so migrating them would be wasted work — but the de-duplication this package exists for is not complete until they go.
License
MIT
