@burojs/mock-kit
v0.2.0
Published
Buro: MSW-backed offline mock backends for tests, e2e, and demos
Readme
@burojs/mock-kit
MSW-backed offline mock backends for tests, e2e, and demos. The engine —
deterministic time/randomness, a persisted store, sessions, tenant/scope
guarding, network fault injection — is domain-free; a domain pack (seed data,
tenantField map, users) plugs into it. pocketbaseAdapter is the one
backend adapter that exists today; it and the engine ship from this same
package because nothing else has needed to be separate yet.
Install
pnpm add -D @burojs/mock-kit mswQuick start
import {
createMockKit,
createRng,
createScopeGuard,
defineDomain,
defineSessions,
} from '@burojs/mock-kit';
import { pocketbaseAdapter } from '@burojs/mock-kit/pocketbase';
import { setupMockServer } from '@burojs/mock-kit/node'; // or './browser' + startMockWorker
const rng = createRng(1);
const domain = defineDomain(() => ({
orders: [{ id: 'o1', seller_id: 't1', total: 10 }],
categories: [{ id: 'c1', title: 'Shared' }], // no tenant field: global, visible to everyone
}));
const sessions = defineSessions({
users: [
{ id: 'u1', email: '[email protected]', password: 'pw', name: 'Ana', roles: [], tenantIds: ['t1'] },
],
rng,
});
const scope = createScopeGuard({ tenantField: { orders: 'seller_id' } });
const kit = createMockKit(domain, [pocketbaseAdapter(domain, { sessions, scope })], {
sessions,
rng, // pass the same instance defineSessions got, or reset() can't rewind it
});
// Node/vitest:
const server = setupMockServer(kit);
server.listen({ onUnhandledRequest: 'error' });
// afterEach(() => kit.reset());
// Browser (Vite/Next):
// await startMockWorker(kit, { serviceWorker: { url: '/mockServiceWorker.js' } });defineDomain seeds a Store and hydrates it from persistence (in-memory by
default — pass { persistence: webStoragePersistence({ storage: localStorage }) }
for the demo case). createMockKit wires the network-fault handler in front
of every adapter and gives you back one reset() that puts the whole world —
store, sessions, faults, and the shared Rng — back to its seeded state.
(A createOperationRunner a long operation's handler closes over is the one
exception: reset() reaches it only if you list it in opsAdapter's
runners option — see "Long operations have NO HTTP route" under
"Operations RPC service" below.)
"Sessions ... back to its seeded state" means every live token is revoked,
full stop — there is no seeded-in session to come back to, since sessions
are minted at runtime, not seeded data. If you signed in (by hand, or via
bootMockKit's launch-parameter as) before calling reset(), that token
is dead afterward and a request made with it gets 401 — the world does NOT
come back "still signed in as before." See "Launch parameters" below for
bootMockKit's reissue(), the seam for getting signed back in as the same
user after a reset without a full page reload.
Storages
memoryPersistence() never survives a reload and never shares state — every
defineDomain call gets its own isolated world. That is what tests want:
parallel test files, or two describe blocks in the same file, must not see
each other's writes.
webStoragePersistence({ storage, channel, key }) is the opposite: it
persists to a Storage (localStorage/sessionStorage) and, if given a
BroadcastChannel-shaped channel, keeps every open tab of the same origin
on the same world. That is what a public demo wants — a visitor who opens a
second tab, or reloads, should not fall back to a blank seed.
The two are not just "persisted vs. not" — they change what a second reader
sees. On save, webStoragePersistence writes to storage and posts on the
channel; a peer that receives that message re-reads storage and calls back
with the new snapshot, but never re-saves or re-posts what it just received.
Skipping that step is deliberate: if the receiving tab re-broadcast the
snapshot it just adopted, the tab that sent it would receive its own write
back as if it were external, re-adopt it, and re-broadcast again — two tabs
would ping-pong the same write at each other forever.
Store.hydrate() (called once by defineDomain) merges rather than trusts
storage verbatim: for every collection the seed currently defines, it takes
the stored rows if storage has that key, or falls back to the seed's rows if
it doesn't. A collection storage still has but the seed no longer defines is
dropped. In other words: the store's shape always follows the code (seed),
but a collection's content — for anything the seed and storage agree exists
— is the visitor's stored edits, not a fresh reseed. This is why a returning
visitor doesn't lose their state when a collection with unrelated changes
ships, but also doesn't get stuck on a schema an old build seeded.
Sessions and tenants
defineSessions({ users, rng }) gives you login, issue, resolve,
revoke, and reset, plus the frozen users roster it defensively copied on
the way in (so nothing downstream — including the caller's own reference to
the array it passed — can mutate a live session or the roster after the
fact).
Tokens are mocktoken_N, minted from Rng.id('mocktoken') — a plain
per-registry counter, not a signed or seed-derived value. That is by design:
this is a test double, not a security boundary, and predictable tokens are
useful for e2e ("set Authorization: Bearer mocktoken_1 and skip the login
UI"), not a bug to fix.
resolve(request, options?) requires the Authorization: Bearer <token>
scheme by default; pass { allowBareToken: true } (ResolveOptions,
re-exported from the package root) to also accept a bare, unprefixed token.
Three of the four adapters that call resolve opt in, each for its own
reason:
pocketbaseAdapteropts in on every one of its ownresolvecall sites, matching real PocketBase's wire convention (no Bearer scheme) and the reusablecreatePocketbaseDataProviderthat mimics it.opsAdapterandauthAdapteralso opt in (mock-fidelity fix round, item 2) — but the two were decided on separate evidence, not by symmetry withpocketbaseAdapter.opsAdapter's own module doc comment says so itself:/api/marketplace/ops"has no live counterpart to snapshot, replay, or diff against."authAdaptercarries no such self-declaration, so it was checked directly: its routes (/auth/login,/auth/me,/auth/switch-tenant,/auth/logout) were hand-written in commit1560c29ewith no fixture and no cited real backend, and don't share a path or response shape with the one real auth service this repository's own tooling actually talks to (/auth/v1/signin/email-password,scripts/lib/hasura-session.mjs,apps/playground/src/auth/nhost-client.ts). That real service is, moreover, never asked — anywhere in this repo — whether it accepts a bare token on one of ITS OWN endpoints: every call that carries its token hands that token straight to Hasura's/gql/v1/graphql, alwaysBearer-prefixed, which is evidence about Hasura, not about the auth service. So the honest statement is no evidence either way for what a real auth service would do here, not "a real auth service accepts this too." With no real backend to be faithful or unfaithful to, the decision to accept the bare token is a consistency choice, not a fidelity one: the kit's own login routes already mint a baresession.token,pocketbaseAdapteralready accepts it back, and an app wired against all three adapters through oneSessionRegistrywould otherwise have to remember which ones need theBearerprefix re-added. A bare-token/auth/mealso fails in a way that is easy to misread — 401 reads exactly like "your session expired" to whatever is built against it, not like a format mismatch.
hasuraAdapter is the one adapter that does NOT opt in, and stays strict on
Bearer — real Hasura's auth webhook requires that scheme, and a globally
lenient resolve would trade one fidelity defect for another.
Combined with createScopeGuard, pocketbaseAdapter answers 404, not
403, for a record the caller's session cannot see — whether that's a GET on
/records/:id, or a PATCH/DELETE that resolves to "not writable". A 403
would confirm the record exists under someone else's tenant; a mock backend
that leaks existence teaches the wrong lesson to whatever's built against it,
so this one always answers as if the record simply weren't there.
ScopeConfig.tenantField maps collection → the field holding its owning
tenant. A collection you don't list there is not "scoped but permissive" —
it is global: visible() returns "everyone can see everything in it,"
unconditionally, for every session including no session at all. There is no
separate flag for "this collection is intentionally global" — the omission
is the declaration. That means a tenant-scoped collection accidentally left
out of the map fails open, silently: every record in it becomes visible
to every caller, with nothing at runtime to warn you. When you add a new
tenant-scoped collection to your domain, add it to tenantField in the same
change — the correctness of every other entry says nothing about this one.
Writes are guarded separately from reads: with a scope configured, a
pocketbaseAdapter create/patch/delete from a request with no
resolvable session is refused with 401, even against a fully global
collection. An anonymous caller cannot plant an unstamped record or
waved-through edit just because the target happens to be visible to
everyone. opsAdapter mirrors the same condition (a coarse, whole-operation
unauthorized, also 401 — see "Operations RPC service" below).
hasuraAdapter enforces the equivalent refusal on mutations too, but in its
own vendor's shape, not a literal 401: an anonymous insert/update/delete
root answers HTTP 200 with a GraphQL errors envelope (access-denied,
"field '<root>' not found in type: 'mutation_root'") — the same status a
genuinely unrouted root produces, and the same "no HTTP error status for a
permissions refusal" convention real Hasura mutations use. Do not assume
"401" as a kit-wide constant; it is the REST-shaped adapters' convention,
not the GraphQL one.
ScopeConfig.globalRoles — a role that spans every tenant
tenantField/scopeFields have no notion of role — they only ever compare
a record's tenant field against session.tenantId. That is not enough to
express a "platform operator" style role that is legitimately supposed to see
every tenant's rows. globalRoles is the narrow escape hatch:
createScopeGuard({
tenantField: { orders: 'seller_id' },
globalRoles: ['platform_operator'],
});A session whose roles includes any name in this list has the tenant
comparison in visible() (and, through it, owns(), which always defers to
visible) lifted entirely — it sees every tenant's rows in every
tenant-scoped collection. The price is exactly that: tenant isolation is
switched off for the roles you name here. Treat the list itself as trusted
mock configuration, never something derived from a request.
Two things this does not touch, on purpose:
- A pinned scope axis still applies. A
globalRolessession withscope.warehousepinned to one value sees that one warehouse across every tenant, not every warehouse of every tenant — the role lifts the tenant boundary, not a caller's own axis pin. available()andstamp()are untouched. A collection gated byrequiresAxisis still absent for aglobalRolessession with that axis unpinned, and a record such a session creates is still stamped with its own tenant — a global role does not mean the records it creates have no owner.
Collection availability — a scope axis can make a collection not exist
ScopeConfig.tenantField/scopeFields filter which rows of an
always-existing collection a caller sees. That is not enough to express
something like "with no warehouse selected, shipments and receiving do
not exist" — an empty list still says the collection is there, just empty.
ScopeConfig.requiresAxis is the other kind of gate:
createScopeGuard({
tenantField: { orders: 'seller_id', shipments: 'seller_id' },
scopeFields: { shipments: { warehouse: 'warehouse_id' } },
requiresAxis: { shipments: 'warehouse' }, // must be pinned, or the collection is absent
});With that config, a session whose scope.warehouse is unset (or null, or
'' — the same "whole of the axis" values visible() already treats as
equivalent) makes every pocketbaseAdapter route for shipments —
list, one, create, patch, delete, and the file route — answer
404 { code: 404, message: 'Missing collection context.' }, borrowing the
message real PocketBase gives for a collection name it doesn't recognise at
all. That borrowing is cosmetic, not a claim of indistinguishability: a
collection name this mock's own store has genuinely never seen still comes
back 200 with an empty page from list/one, not this 404 — "no rows yet"
and "this axis isn't pinned" stay two different, checkable outcomes. Once
the axis is pinned to a concrete value (?scope_warehouse=w1, or
sessions.issue(userId, tenantId, { warehouse: 'w1' })), the collection
reappears and rows are filtered by scopeFields as usual.
The 404, not 401 or 403 choice here is deliberate, and consistent with
the rest of this section: an unauthenticated create against an
axis-gated collection still gets 404, not the 401 an unauthenticated write
against a real collection would get — a 401 would itself leak "this route
exists and requires auth," which is exactly the kind of existence leak the
tenant-isolation 404s above already refuse to produce. available() is
checked before every other guard in pocketbaseAdapter, precisely so no
other status code can leak past it. A collection with no requiresAxis
entry is unaffected — this is opt-in per collection, same as tenantField.
Token lifetime is a policy the auth adapter (and, when session-aware,
pocketbaseAdapter's own auth-with-password) enforces, not something
SessionRegistry decides on its own — issue/login mint tokens with no TTL
and no cap:
loginretires every live token that user currently holds — however it was minted (a previous login, or any number of priorswitch-tenantcalls). A fresh login is a new authentication event, and this is also the only thing that bounds the token map's growth, sinceswitch-tenantcan mint arbitrarily many tokens per user between logins.switch-tenantretires nothing. The pre-switch token stays valid on purpose — a request already in flight against the old tenant, or a second tab still open on it, keeps working. Its new token is only tracked so that the next login for that user sweeps it up too.logoutretires only the one token it was called with. It does not reach across and kill that user's other live sessions (a different device's login, or a token picked up viaswitch-tenant).
Determinism
Math.random() and Date.now() are banned from the kit and from domain
packs — either one makes a seed meaningless, since two runs of the same seed
could then sort, paginate, or timestamp differently. createRng(seed) (a
small mulberry32 generator) and createClock(startIso, stepMs) are the only
sanctioned sources of randomness and time; use them in every seed factory and
every place that would otherwise reach for Math.random/Date.now.
Both expose reset(). Rng.reset() restores the seed's initial internal
state and clears every id() prefix counter — both halves matter,
because store.reseed() alone puts the store's rows back but has no idea an
Rng exists, so without also resetting it, generated ids and any
seed-derived content drift further from the seed on every reset. Pass the
same Rng instance to defineSessions({ rng }) and to createMockKit(...,
{ rng }) (and to your seed factory, if it generates anything) so
kit.reset() can rewind all of it together — kit.reset() only resets the
rng if you gave it one.
PocketbaseAdapterOptions.ids — a caller-supplied id factory
pocketbaseAdapter's create handler otherwise always mints a new record's
id from store.nextId(collection), which produces <collection>_<n> —
readable, but not the shape a real backend hands out (a uuid, most commonly).
When some other part of the mock — a relation, a fixture, a route a demo
script hits by a known id — needs to predict or match that shape, ids lets
you override it per collection:
pocketbaseAdapter(domain, {
ids: { order: () => crypto.randomUUID() },
});The price: this option does not check for collisions. store.insert
keys rows by id, so a factory that returns an id already in use silently
overwrites that row rather than erroring — uniqueness is entirely the
factory's own responsibility. The factory is called exactly once per create;
if it draws from a shared, seed-shared Rng (the deterministic-id
convention described above), a second call would shift that Rng's stream
for everything drawn from it afterwards. A collection with no entry in ids
keeps getting store.nextId(collection), unchanged — opt-in per collection,
same as tenantField/requiresAxis.
Network control
createNetworkProfile() gives you setLatency(ms) and failNext({ match?,
status, body?, times? }); networkHandler(profile, { prefix? }) is an MSW
handler that must be registered before any adapter (createMockKit does this
for you when you pass network, and forwards networkPrefix to it). It only
looks at requests whose path contains prefix (default /api) — everything
else, e.g. the page's own JS/CSS/fonts/images, falls straight through
untouched. For a matching request, it applies the configured latency and then
— only if a fault rule matches — answers with it instead of letting the
request reach the adapter.
Latency runs on both paths, fault or not: setLatency(300) +
failNext({ status: 500 }) together is exactly how you test "spinner, then
error" — if latency only applied to the happy path, that combination could
never be exercised, since the fault would resolve instantly.
failNext's match is a plain substring test against the request URL, not a
route pattern — good enough for a handful of deliberately chosen URLs in a
test, but a short or numeric match can coincidentally hit more than you
meant (a rule for /api/orders also matches /api/orders/42). times
defaults to 1 and consumes down to removal; times: 0 registers nothing at
all, rather than a rule that silently fires once anyway.
In the browser, startMockWorker(kit, { network }) attaches
window.__mockControl (reset, setLatency, failNext) before awaiting
service-worker activation, so an e2e test can arm a fault or reseed the world
immediately — none of those calls touch the worker itself, only the
kit/network state, so there's no reason to make them wait.
One MSW caveat worth knowing before reaching for it: MSW intercepts HTTP
through the service worker (or, in Node, through interception of fetch),
but it intercepts WebSocket traffic by replacing the global WebSocket
class — a different mechanism, invisible in the browser's Network tab, and
verified to be the SAME mechanism in Node and browser alike (setupServer
and setupWorker both sit on @mswjs/interceptors/WebSocket; what differs
is setupWorker refusing to run under Node at all, and the service-worker
registration path). hasuraWsAdapter (see "WebSocket subscriptions" below)
is the one adapter in this package that speaks it.
Launch parameters
parseMockOptions(search) reads a URLSearchParams-shaped string (with or
without a leading ?) into a MockStartOptions:
| param | meaning | default |
| ------------ | ------------------------------------------------------------------------ | ---------- |
| mock | local selects webStoragePersistence; anything else (or absent) is in-memory | memory |
| seed | numeric seed passed to createRng. A non-numeric value (?seed=abc) warns via console.warn and falls back to the default; an absent seed falls back silently. ?seed=0 and ?seed=-5 pass through unchanged — they are not treated as "no seed given." | 1 |
| as | email of the user to start signed in as | anonymous |
| tenant | tenant id to start on, when as belongs to more than one | first tenant for as |
| scope_* | any scope_<axis>=<value> param becomes scope[axis] = value | {} |
By itself, parseMockOptions is only a parser — it does not build anything.
bootMockKit(options) is what actually consumes it: one call that turns a
launch query string into a running, already-signed-in kit — persistence
selected, Rng seeded, sessions built, and (if as was given) a session
issued and ready, all before your app renders a single pixel. This is the
piece that makes an e2e run skip the login UI entirely, per spec §9.2: one
spec exercises the real login form, every other spec loads a URL and is
already signed in.
import { bootMockKit } from '@burojs/mock-kit';
import { pocketbaseAdapter } from '@burojs/mock-kit/pocketbase';
import { startMockWorker } from '@burojs/mock-kit/browser';
const USERS = [
{
id: 'u1',
email: '[email protected]',
password: 'pw',
name: 'Ana',
roles: ['merchant_admin'],
tenantIds: ['t1', 't2'],
},
];
const boot = bootMockKit({
seed: (rng) => ({
orders: [{ id: rng.id('order'), seller_id: 't1', total: 10 }],
shipments: [{ id: rng.id('shipment'), seller_id: 't1', warehouse_id: 'w1', label: 'box' }],
}),
users: USERS,
scope: {
tenantField: { orders: 'seller_id', shipments: 'seller_id' },
scopeFields: { shipments: { warehouse: 'warehouse_id' } },
requiresAxis: { shipments: 'warehouse' },
},
// Only needed if a launch URL might ask for `mock=local`:
webStorage: { storage: localStorage, channel: new BroadcastChannel('buro-mock') },
adapters: ({ domain, sessions, scope, clock }) => [
pocketbaseAdapter(domain, { sessions, scope, clock }),
],
});
await startMockWorker(boot.kit, {
serviceWorker: { url: '/mockServiceWorker.js' },
session: boot.session, // reachable at window.__mockControl.session
reissue: boot.reissue, // reachable at window.__mockControl.reissueSession()
});
// The app's own auth bootstrap reads `boot.session`/`boot.token` (or, once
// the worker is up, `window.__mockControl.session?.token`) instead of
// showing a login screen — for a page loaded with no `as` param, both are
// `undefined` and the app boots anonymous, exactly as before this existed.A page loaded as [email protected]&tenant=t2&scope_warehouse=w1 boots
with boot.session already issued for Ana, tenant t2, pinned to warehouse
w1 — no fetch to /auth/login, no form. A page loaded with ?as= set to
an email with no matching MockUser, or a tenant that user does not
belong to, does not fall back to anonymous: bootMockKit throws,
immediately, at boot — a test that believes it is signed in as someone it is
not is a worse failure mode than one that visibly isn't signed in at all. A
page loaded as ?mock=local with no webStorage option configured throws
the same way, rather than silently downgrading to in-memory (which would
quietly break the "second tab sees the same world" guarantee mock=local
promises).
boot.token does not survive boot.kit.reset(). reset() revokes
every live session — see the caveat under "Quick start" above — and the
boot-issued one is not special-cased to survive it: a test that resets
specifically to check a signed-out world must not find itself silently
still signed in just because it happened to boot via as. This is the exact
e2e shape spec §9.2 runs (boot signed in → run a spec → reset → run the
next spec), so bootMockKit gives it an explicit seam instead of leaving it
to a page reload: boot.reissue() re-runs the same as/tenant/scope_*
resolution and returns a fresh session (undefined, harmlessly, for a boot
that was anonymous to begin with). It does not update boot.session/
boot.token in place — those stay as they were at boot — so use its return
value going forward:
boot.kit.reset();
const fresh = boot.reissue(); // same user, same tenant, same scope — new tokenIn the browser, pass reissue: boot.reissue to startMockWorker (as in the
snippet above) and call window.__mockControl.reissueSession() after
window.__mockControl.reset() — that variant does update
__mockControl.session in place, so re-reading it afterward sees the fresh
token.
bootMockKit's seed receives the boot's own Rng (built from seed, or
1) — close over it for any rng-derived id/content, the same way you would
when not using bootMockKit; it drives both the store and
sessions.issue's tokens, and boot.kit.reset() rewinds it, so "same seed,
same world" holds for the whole assembled kit, not just the store.
Fidelity boundaries
Today this package ships two backend adapters: pocketbaseAdapter and
hasuraAdapter.
pocketbaseAdapter implements a subset of PocketBase's HTTP API — records
CRUD, filter/sort/pagination on GET /api/collections/:collection/records,
auth-with-password, and placeholder responses for file fields. Nothing
beyond that: no PocketBase realtime/SSE, no batch API, no relation
expansion (expand), no field-level validation rules.
authAdapter (the ./auth subpath) is a separate, PocketBase-independent
login/me/logout/switch-tenant surface over the same SessionRegistry —
useful when the app being mocked isn't talking to PocketBase at all.
Two more surfaces exist beyond these three, added by the SP1c sub-project —
hasuraWsAdapter (WebSocket subscriptions, "WebSocket subscriptions" below)
and opsAdapter/createOperationRunner (a neutral RPC surface, "Operations
RPC service" below). The two have deliberately different epistemic
status, and it matters which is which: hasuraWsAdapter REPRODUCES
captured reality — two real graphql-ws frame sequences were captured
against the live Hasura gateway, and every claim below about handshake
ordering or re-push shape is traceable to one of those two fixtures. The ops
service is DESIGNED, not captured: /api/marketplace/ops has no live
counterpart anywhere, so nothing about its routes, error envelope, status
codes, or check order was ever observed — every one of those is a choice
this package made, recorded as such at the point it's made (and again
below), not a fact discovered by capturing a real backend.
Hasura adapter — what it implements
hasuraAdapter (the ./hasura subpath) serves one Hasura-shaped endpoint,
POST <basePath>/v1/graphql, over an ALLOW-LIST schema the caller declares
via HasuraAdapterOptions.tables (Hasura root name → store collection) AND
HasuraAdapterOptions.schemas (Hasura root name → declared column types) —
schemas is MANDATORY, and every option that names a table
(relations/conflictKeys/deleteForbiddenTables) is keyed by the same
Hasura root name tables uses, not the store collection name. A table
declared in one of tables/schemas but not the other, a relation naming an
unknown table/column, or a conflictKeys/deleteForbiddenTables entry
naming an unknown table — all throw at CONSTRUCTION time, before the first
request, rather than producing a confusing (or silently wrong) answer on
whichever request happens to touch the broken part first. Everything below
was proven against real captured fixtures, not written from the
GraphQL/Hasura spec from memory — anything a fixture never exercised is
refused (validation-failed), not guessed at, because a mock that guesses a
permissive answer teaches the wrong lesson to whatever is built against it.
fixtures/README.md is the source of every fact in this section and the
next — read it for the fixture-by-fixture evidence behind each claim here.
Table schema — mandatory, one declared type per column
Every table named in HasuraAdapterOptions.tables must have a matching
entry in HasuraAdapterOptions.schemas, and every schemas entry must name
a table tables actually routes — both directions are checked at
construction time (see "Construction-time checks" below). A TableSchema is
{ columns: Record<string, ColumnDef> }; each ColumnDef is { type,
nullable?, generated? }. type is one of 'bigint' | 'uuid' | 'text' |
'timestamptz' | 'boolean' | 'jsonb' — five of the six are the vocabulary
SP0's captured fixtures actually exercise: bigint (integers), timestamptz
(timestamps), boolean, text (plain, possibly-null-nullable strings),
and jsonb (document_document's head/meta/error/system_meta,
reference_freeform_templates.config, document_document_data.data — see
tests/hasura-fidelity.test.ts's own schema declarations for each). The
sixth, uuid, is the one type the live capture never needed (none of the
five tables it covers has a uuid-keyed id) but this package's own test
domain does, to prove the uuid id path end to end rather than leave it
unreachable.
generated ('created' | 'updated', meaningful only on a timestamptz
column — and, since the final id-semantics fix-wave, ENFORCED: declaring it
on any other column type throws at construction, see "Construction-time
checks" below) is what replaced the removed timestamps option — see
"Removed options" below. nullable (default true) is DOCUMENTARY ONLY: nothing
in this adapter reads it. The design intended it to fill an omitted column
with null on insert; that was never implemented, and an insert that omits
a non-null column is not refused either — declaring nullable: false
records intent for a human reader, not an enforced rule. See "Known,
deliberate precision boundaries" below.
The three id regimes. Every table's id column behaves one of three
ways, chosen entirely by its declared type:
| id column type | Comparison | Wire shape | Fresh-insert id | A target that can't match |
| --- | --- | --- | --- | --- |
| bigint | bare ===, both sides number | JSON number | max(existing numeric ids) + 1, refuses past Number.MAX_SAFE_INTEGER | data-exception |
| uuid | bare ===, both sides string | JSON string | random, canonically-shaped v4 uuid drawn from the adapter's configured rng | data-exception |
| text | bare ===, both sides string | JSON string | not generated — object.id is REQUIRED; omitting it (or sending null) answers validation-failed, because a natural key like WH-BCN-01 is domain-specific and the mock refuses to invent one | n/a — a text id has no shape to validate against |
The store (store.ts) now holds an id in whichever of these types the
adapter gave it (Rec['id'] is string | number), and every comparison
site in the package — where in a query, where in a mutation, all three
_by_pk paths, on_conflict's conflict-column matching, and the FK join a
relation performs — is a bare === against a value already agreed on type.
The stored value itself is NEVER coerced anywhere; the one thing that still
gets parsed is the incoming TARGET (a where/pk_columns/_by_pk/
on_conflict.object.id argument), through identity.ts's coerceIdTarget,
resolved to the table's DECLARED id column type (idColumnOf) at every
one of those SIX sites — query.ts's buildPredicate/runByPk and
mutation.ts's buildEqPredicate/both _by_pk mutation paths/
findConflictRow all dispatch the same way now. Before the final
id-semantics fix-wave this dispatch was gated on a bigint-only boolean
(bigintIdCollections?.has(...)) on the READ side alone, and not gated at
all — no coercion, ever — on any of the other five: a bigint _eq: "101"
(a digit-only STRING target) matched on read but not on write, a
case-different uuid target (Postgres itself folds uuid case) never
matched anywhere, and — the damaging direction, caught by a re-review after
the rest were closed — on_conflict's own conflict-column matching
(findConflictRow) silently failed to recognize a REAL conflicting row
whenever its id target needed coercion (a digit-string against bigint,
a case-different uuid), so the insert fell through to a fresh row: a
DUPLICATE sharing the same logical id, written silently, HTTP 200, no
error — worse than the read-side gap, which only ever answered an
over-cautious empty list. Dispatching on the table's actual declared type,
at every site, closed all of this at once — see
tests/hasura-id-types.test.ts's uuid case-folding and malformed-target
refusal, on every id-comparison path describe block for the where/
_by_pk/write-where proof, and the dedicated
tests/hasura-mutation-onconflict-id.test.ts for the on_conflict proof
(a bigint digit-string target and a case-different uuid target each
updating the real conflicting row instead of inserting a duplicate).
Removed options — bigintIdTables and timestamps no longer exist on
HasuraAdapterOptions. Both were narrower, separately-opt-in predecessors of
what schemas now expresses in one place: bigintIdTables (a Set of
table names) is now simply type: 'bigint' on that table's id column;
timestamps (a per-table {created?, updated?} column-name map) is now
generated: 'created'/'updated' on the relevant timestamptz column.
There is exactly one option keyspace now: every option that names a
table — tables, schemas, relations, conflictKeys,
deleteForbiddenTables — is keyed by the Hasura root name tables itself
uses, never the store collection name.
Construction-time checks. hasuraAdapter() throws before the first
request is ever served, for any of:
- a table declared in
tableswith no matchingschemasentry; - a
schemasentry for a tabletablesdoesn't route; - a relation (
RelationConfig.collection) targeting a tabletablesdoesn't route; - a relation's
foreignFieldnot declared in the TARGET table's schema; - a
conflictKeys/deleteForbiddenTablesentry naming a tabletablesdoesn't route; - a relation's
localField, or a column aconflictKeysconstraint lists, not declared in the relevant table's schema; - a table's
schemasentry declaring noidcolumn at all —deriveBigintIdCollections(adapter.ts) callsschema.ts'sidColumnOffor every routed table while deriving the internal bigint-id set, andidColumnOfthrows unconditionally when a table's schema has noidentry. This runs at construction time, before the request handler is even assembled, exactly like checks 1-6 — not merely on the first request that happens to touch the table.
(adapter.ts runs two more checks alongside these seven, both added by
review rather than in the original plan: a relation declared for a table
that is itself not in tables at all — closing an edge the seven above
only caught incidentally, and only when that table's relation set happened
to be non-empty — and, from the final id-semantics fix-wave:
- a relation's
localFieldandforeignFielddeclaring DIFFERENT column types (bigintjoined againsttext, for example) — checks 4/6 above only confirm both columns EXIST, never that they agree on type, and a type mismatch constructs cleanly today only to resolve to a permanently-null/always-empty relation for every row, with no error anywhere; - a column declaring
generatedon anything other than atimestamptztype —generatedis only ever meaningful on atimestamptzcolumn (schema.ts's ownColumnDefdoc comment), and nothing checked that before, so a copy-paste error could silently stamp a non-timestamp column with aclock.now()ISO string.
There is also a TENTH throw reachable in hasuraAdapter()'s construction
body that this numbered list, for a while, omitted:
flattenConflictKeys's duplicate-constraint-name throw (two different
tables declaring the same on_conflict constraint name) — real, already
tested (tests/hasura-adapter-schema.test.ts's "conflictKeys constraint
name collision across two different tables throws at construction"), just
never catalogued here. columnOf's own "no table declared" throw is
genuinely UNREACHABLE from hasuraAdapter() once checks 1-3 have run — it
is listed here only because grepping the source finds an eleventh throw
that looks reachable and isn't; the ten above are the complete, provable
catalog.)
Root fields, one set per table declared in tables:
- Reads:
<table>(list),<table>_by_pk,<table>_aggregate. - Writes:
insert_<table>_one,update_<table>_by_pk,update_<table>,delete_<table>_by_pk,delete_<table>. - Any other root name — a table never declared in
tables, or a name using the wrong operation kind's shape (e.g. a mutation-shaped root inside aquerydocument) — isaccess-denied, never a 404 or a quietly empty result.
List (<table>) arguments: where, order_by, limit, offset,
distinct_on. where operators: _eq, _in, _is_null, combined with
implicit AND across fields — no _gt/_lt/_like, or any other operator.
where also supports the boolean combinators _and/_or/_not (SP3a task
1, captured live against a production Hasura backend —
fixtures/hasura/where-*.json, see fixtures/README.md): _and/_or take
an array of nested where fragments and intersect/union them, _not takes a
single fragment and negates it, they nest inside each other and beside a
plain field (ANDing together with it the same way two plain fields do), and
the empty-array forms are pinned to real backend behaviour: _and: [] is
vacuously TRUE (matches every row), _or: [] is vacuously FALSE (matches
none). A combinator key is recognized before a top-level where key is ever
read as a field name; any other _-prefixed key still throws. order_by
accepts a plain column or one level of
relation ({location: {name: asc}}); with none given, the default is
ascending id — matching the live backend's own default order, not
Map-iteration order (see "_in does not preserve order" below for the
fixture that proves it). distinct_on only as a single bare field name
(always paired with an order_by, matching how the live backend actually
uses it) — the compound multi-field form is refused. <table>_aggregate
only takes where; its aggregate { } selection only supports count and
max { <field> } — no min/avg/sum, no sibling nodes.
<table>_by_pk takes only a bare id (string or number); a miss
returns null, never an error (by-pk-missing.json).
Mutations: insert_<table>_one takes object and an optional
on_conflict: {constraint, update_columns} — a constraint name must be
declared in HasuraAdapterOptions.conflictKeys first (keyed by Hasura table
name, then by constraint name: { [hasuraTable]: { [constraint]: columns } }
— construction-time checks confirm the table is routed and every listed
column is declared in that table's schemas entry), since this mock has no
Postgres catalog to resolve a constraint name to columns on its own. A
table's id column follows whatever schemas declares for it: bigint gets
a generated sequential id that serializes as a number over the wire (a
client-supplied object.id is never honoured on a bigint table — insertId
always calls generateId for it, the same as uuid — see "fidelity sweep
findings" below), uuid gets a random id drawn from the adapter's configured
rng, and text REQUIRES a client-supplied id (the mock never invents a
natural key — an insert with no id on a text-id table answers
validation-failed).
update_<table>_by_pk takes pk_columns (a bare {id} only — a compound
key is refused) and _set. update_<table>/delete_<table> take
where/_set or where respectively, and where on the WRITE side only
ever supports _eq per leaf field — no _in/_is_null there, since no
captured write fixture uses them. The _and/_or/_not combinators ARE
supported on the write side too (same task), with the same semantics as the
read side — a write's where is the same <table>_bool_exp GraphQL input
type as a read's, so the boolean-combination rule the read-side capture
proved applies unchanged; only the per-leaf operator restriction (_eq
only) differs between the two, and that restriction was already true before
this task. delete_<table>_by_pk takes a bare id.
Schema authority on writes now covers insert, _set, and
on_conflict.update_columns alike. insert_<table>_one's object is
validated against the table's DECLARED schemas entry (mutation.ts's
knownColumnsOf/assertKnownColumns): an unknown column always throws
validation-failed, on a populated table AND on a genuinely empty one — the
schema's column list exists whether or not any row has been written yet,
closing what used to be a real gap ("an insert into a table with zero rows
can't be checked, so it silently accepts anything"). update_<table>_by_pk/
update_<table>'s _set (buildSetPatch) and on_conflict.update_columns
(applyOnConflictUpdate) now consult the SAME knownColumnsOf — the final
id-semantics fix-wave's Critical 2 fix — falling back to the row-derived
check only when table itself has no schemas entry at all. Before that
fix, both paths validated against the TARGET ROW's own observed keys, which
had a damaging failure mode: a DECLARED, nullable column genuinely absent
from a hand-seeded row (this mock's whole purpose is running against
hand-written seeds, not only rows the adapter itself inserted) made a
legitimate _set/on_conflict.update_columns write to that column throw
Unknown column, even though the identical column was readable (serializes
null) and insertable. There is no longer a gap between what a fresh
insert accepts and what a later update/on_conflict on the same table
accepts.
Relations: one level of object (resolves to a row or null) or
array (resolves to a list) relation, declared explicitly per Hasura table
name via HasuraAdapterOptions.relations — never inferred from
foreign-key-shaped column names. Both the declaring table (the outer key)
and the related table (RelationConfig.collection) are Hasura table names,
not store collection names; construction-time checks confirm the related
table is routed, that localField/foreignField are declared columns of
the declaring/related table's schemas entries, respectively, and — since
the final id-semantics fix-wave — that the two declare the SAME column type
(a bigint-vs-text join constructs cleanly otherwise, then silently
resolves to a permanently-null/always-empty relation for every row). An object
relation accepts no arguments at all; an array relation accepts only
limit/order_by (no where/offset/distinct_on nested inside a
relation selection — no fixture ever does that).
Not implemented at all, and refused (validation-failed) rather than
silently answered: GraphQL subscriptions over this adapter (HTTP POST only —
see the WebSocket paragraph above); fragments and directives in the query
document; any where operator beyond the ones listed above (_and/_or/
_not ARE implemented now — see the list-arguments paragraph above);
min/avg/sum aggregates or a sibling nodes on an aggregate root; a
compound pk_columns or multi-field distinct_on; and any argument on a
nested relation beyond limit/order_by on an array relation.
Also not implemented, but silently so rather than refused — transactional atomicity across multiple mutation roots in one document. A throw on a later root does NOT roll back writes already committed by earlier roots in the same document, so a client that correctly handles the error can still be left with a partially-applied write. See "Multi-root mutations are not atomic" below.
Error shape — always HTTP 200, distinguished only by extensions.code
Every GraphQL-level failure this adapter can produce — a parse error, an
unroutable root, a refused argument/operator, a genuine constraint
violation, an invalid credential — comes back as HTTP 200 with no
data key at all, only an errors array:
{ "errors": [{ "message": "...", "extensions": { "path": "$", "code": "validation-failed" } }] }This is captured behaviour, not a GraphQL-spec default — SP0's
error-unauthorized.json/error-permission.json/etc. all show the real
backend doing exactly this (see fixtures/README.md). A consumer that
checks response.status to detect a GraphQL error will never see one from
this adapter, exactly like the real backend. The codes this adapter can
emit: validation-failed (a malformed or unimplemented argument/operator —
the catch-all), data-exception (a where target that cannot possibly
match its column's type, e.g. a non-numeric _eq against a bigint id),
access-denied (an unroutable root field name), invalid-jwt (a presented
Authorization header that does not resolve to a session), and
constraint-violation (an on_conflict target that exists but is invisible
to the caller, or a delete against a deleteForbiddenTables table — see
below).
_in does not preserve the requested order
where: {id: {_in: [...]}} returns matching rows in the table's own default
order (ascending id), not the order the array was given in — proven
against the live backend, not assumed: batch-in.json requested ids
[3154, 3153, 3245, 3312, 3314] and got back
[3153, 3154, 3245, 3312, 3314]. A consumer that needs the caller's order
must re-sort client-side; this mock reproduces the live re-ordering rather
than the more convenient "preserve request order" behaviour, because that
convenience isn't what the real backend does.
deleteForbiddenTables — a delete that can never succeed
HasuraAdapterOptions.deleteForbiddenTables (a Set of Hasura table names —
the same keyspace tables uses, checked at construction time to name only a
routed table) declares a table where a delete must always fail — added for
document_document's real AFTER DELETE trigger defect (see
error-delete-history-trigger in fixtures/README.md). A
delete_<table>_by_pk/delete_<table> against a declared table answers
constraint-violation, checked after every argument is validated but
before the store is ever touched: a malformed where/id on a
delete-forbidden table still reports validation-failed, not
constraint-violation, because document validation and runtime execution
are different failure layers on a real GraphQL server, and this refusal
belongs to the second one. A table absent from this set is completely
unaffected — nothing here polices it automatically, because this mock has no
trigger/constraint system of its own to discover the defect on its own; it
must be declared, the same way conflictKeys must be.
Multi-root mutations are not atomic
A single mutation document can carry more than one root field (mutation {
a: insert_x_one(...) { id } b: delete_y(where: ...) { affected_rows } }).
adapter.ts executes each root in a plain loop and writes its result under
its own response key; there is no transaction wrapping the whole document.
If a later root throws, the roots executed before it have already been
written to the store — nothing rolls them back — while the HTTP response
still comes back with no data key at all (see "Error shape" above), the
same as any other failure. A client that correctly handles the error can
still be left with a partially-applied write: from the response alone it
sees only an error, but part of the document's effect already landed in the
store. Real Hasura runs every mutation root of one document inside a single
Postgres transaction, so a failure on any root rolls back all of them; this
mock does not reproduce that. Deliberately left as a documented divergence
rather than fixed with snapshot/rollback machinery — send mutation roots
that must succeed or fail together as separate requests if that matters to
your demo.
Hasura adapter — fidelity sweep findings (Task 6)
hasuraAdapter (the ./hasura subpath) now exists — built across Tasks 1-5
of the 2026-08-11-sp1b-hasura-adapter sub-project — and every fixture in
fixtures/hasura/ has been replayed through it and shape-compared against
the real capture (tests/hasura-fidelity.test.ts; a full capability writeup
belongs to that sub-project's Task 7, not repeated here). Two things this
sweep learned about the LIVE BACKEND that were not known before, plus how the
adapter now models them:
Every table's
idcolumn serializes as a bare JSON number (e.g."id": 3314), never a quoted string — true across all five tables this sub-project's fixtures cover (document_document,reference_locations,company_company,reference_freeform_templates,document_document_data).hasuraAdaptercloses the gap at the HTTP boundary: declare the table'sidcolumntype: 'bigint'inHasuraAdapterOptions.schemas(the id-semantics sub-project's Task 4 removed the original, separately-opt-inbigintIdTablesoption and folded its behaviour into this one declaration — there is no other opt-in anymore), and both the response shape (idcomes back numeric) andwhere: {id: {_eq: ...}}/{_in: ...}matching follow — the STORE now holds abigint-declared table's id in its naturalnumbertype (the id-semantics sub-project's Task 5), so a numeric target compares equal via a bare===; there is no string-to-number coercion left on this path at all. A target that cannot represent an integer at all (_eq: "not-a-number") now throws and answersdata-exception, matching the real backend, instead of silently matching zero rows. A FRESH INSERT into abigint-id table also gets a purely-numeric id (identity.ts'sgenerateId/nextBigintId, one past the table's current highest numeric id) instead ofstore.ts's own generic<table>_<n>format — without this, an insert-then-filter round trip (the canonical create-a-row-then-look-it-up flow) would throwdata-exceptiontrying to filter by the very id the mock had just handed back, and a plain list of the table would come back with a MIX of numeric and string ids in the same array. Fix round 1 (reviewer-found Critical 1); see the task-6 report for the full repro.Mis-seeded risk, split into two very different outcomes (read before declaring a table
bigint): what happens to a hand-seeded (orStore.insert-bypassed) row whoseidis not a genuinenumberon abigint-declared table depends entirely on which shape the bad id has.- A digit-only string id (
'5', matching/^\d+$/) is caught loudly, but only on paths that PROJECT the row:select.ts'sprojectRowthrows a plainErrorthe moment that row'sidfield is read back (anylist/_by_pk/relation response that includes it). A path that only COMPARES against a target —where: {id: {_eq: ...}}in a query, or a mutation'swhere— never callsprojectRowat all, so that same row is simply invisible there instead: the stored'5'is never===the coerced numeric target5, so it silently fails to match rather than throwing. A mis-seeded digit-string id is therefore loud on read, quiet (empty results, not an error) on every filter. - A non-digit-only id (
'abc', a UUID, anything with a non-digit character) hits neither check:PURE_DIGITSnever matches it, soprojectRowserializes it as a string exactly as stored, right next to every genuinely-numeric row in the same table serializing as a number — reproducing the exact heterogeneous-array shape this option exists to prevent ({"items":[{"id":1},{"id":"abc"}]}). Nothing in this adapter validates that abigint-declared table's seed is actually all-numeric, and it will not throw or warn if it isn't — a genuine, open sharp edge, not silently swept under the rug: if you declare a table bigint-id, make sure every row your seed (and every write path) ever gives it truly has a numeric-lookingid.
The
Number.MAX_SAFE_INTEGER(253 - 1) ceiling, and what is and isn't handled at it:** a JSnumber— and therefore a bare JSON number,JSON.stringify's only way to emit one — cannot represent an integer past this magnitude exactly.identity.ts'snextBigintId(relocated out ofmutation.tsby the id-semantics sub-project; same algorithm) usesBigIntinternally and REFUSES (throws) rather than hand out a fresh id past this boundary — the write side of the ceiling. This throws a plain, loudErrorrather than silently rounding — which, before fix round 3, is exactly what used to make two DIFFERENT rows serialize with the IDENTICAL projectedid.On the READ side,
select.tsno longer has a magnitude-specific backstop of its own: as of the id-semantics sub-project, ANY digit-only stringidon abigint-declared column throws unconditionally (see "Mis-seeded risk" above), whether or not that string happens to be pastNumber.MAX_SAFE_INTEGER— the narrower, magnitude-only check this paragraph used to describe on the read side has been subsumed by that broader, simpler rule. What is NOT, and cannot be, handled: a client-sentidvalue that arrives as a bare JSON number (or a bare GraphQLIntliteral in the query text, parsed bydocument.ts) past this same magnitude has already lost precision before this adapter's code ever runs —JSON.parse/the GraphQL parser's own integer handling did that, not this package, and there is no way to recover the "true" value after the fact. Sending it as a GraphQL STRING-typed variable does NOT avoid this anymore, unlike before the id-semantics sub-project:identity.ts'scoerceBigintTargetnow converts a numeric-string target to anumbertoo (the store holds abigintid in its naturalnumbertype end to end, per "The three id regimes" above), so a string target pastNumber.MAX_SAFE_INTEGERis refused with a loudError, exactly like a bare-number target that far out — not silently passed through unconverted the way the pre-id-semanticscoerceIdTargetused to.- A digit-only string id (
A credential that was OFFERED but fails to verify is never silently downgraded to anonymous.
error-unauthorized.json's deliberately malformed bearer token proves this on the LIVE BACKEND: the real gateway answersinvalid-jwt, not a 200.hasuraAdaptermakes the same distinction — anAuthorizationheader present but unresolvable bySessionRegistryanswersinvalid-jwt, only when the adapter was actually given asessionsregistry to begin with.Not corroborated by any capture, and stated separately on purpose: what happens with NO
Authorizationheader at all is a MOCK behavioural choice (hasuraAdapter's pre-existing, Task-5 "no session → 200, empty lists, no throw" design), not a sweep finding about the real backend — no fixture was ever captured without anAuthorizationheader, so the real gateway's behaviour for a fully anonymous request (falls back to an unauthenticated role? answers a different error? something else?) is simply unknown. Do not read the first paragraph as implying the second is equally verified.
One narrower finding remains an accepted, permanent boundary rather than fixed, because closing it for real would require a GraphQL type system for INPUT OBJECTS this mock deliberately does not have:
error-invalid-mutation.json(a GraphQL variable declared with a bogus type name) — schema-level variable-type validation needs a schema for input-object types (document_document_set_inputand friends), not just scalar column types; the mutation simply executes with whatever value the variable actually holds.
error-unknown-field.json is CLOSED, not an accepted boundary anymore.
This section originally recorded it alongside error-invalid-mutation.json
above, with a follow-up idea attached: a DECLARED (not row-derived) column
list per table would let select.ts distinguish "not a real column" from "a
real, nullable column with no value on this row." A LATER sub-project
(id-semantics, Task 7) built exactly that: projectRow now consults
HasuraAdapterOptions.schemas via columnOf for every non-relation field a
selection names, and a field that isn't one of the table's declared columns
throws (validation-failed) instead of quietly returning null. A declared
column that is genuinely absent from a given row still reads as null,
unchanged — only a field that isn't declared AT ALL is now refused.
mutation.ts's knownColumnsOf is the WRITE side's own version of the same
DECLARED authority, and has been schema-first since Task 7 — not row-derived,
and not something Task 7 skipped. What Task 7 actually made schema-first on
the write side was insert's object; _set and on_conflict.update_columns
were the two write paths still validating against the target row's own
observed keys at that point. The final id-semantics fix-wave's Critical 2
fix closed that gap too: buildSetPatch (_set) and applyOnConflictUpdate
(on_conflict.update_columns) now call the SAME knownColumnsOf object
already used, so all three write paths share one DECLARED authority — see
"Schema authority on writes now covers insert, _set, and
on_conflict.update_columns alike" above.
Fixture accounting, current: 37 fixtures total (8 added by SP3a task 1's
where-*.json combinator captures — _and/_or/_not, nesting, a
combinator beside a plain field, both empty-array forms), 0 skipped, 1
documented divergence (error-invalid-mutation), 36 fully verified. The
skipped
bucket, once 2 (ws-subscription/ws-subscription-delta), is now genuinely
empty: SP1c's hasuraWsAdapter (see "WebSocket subscriptions" below)
replays both captured WS fixtures over a real socket
(tests/hasura-fidelity.test.ts's replayWsFixture), asserting the exact
frame-TYPE sequence and, for every next frame, its payload SHAPE — the
same positive standing every non-divergent HTTP fixture already has, not a
weaker "it didn't throw" check. tests/hasura-fidelity.test.ts's own
coverage-accounting test pins these four numbers, so a silent regression in
either direction — a fixture quietly falling out of "fully verified," or a
new fixture landing in no bucket at all — fails the suite rather than going
unnoticed.
A third finding, error-delete-history-trigger.json, was originally
recorded alongside the two above under the same "requires a GraphQL schema"
rationale — but that rationale never actually applied to it, and it is now
closed, not an accepted boundary: delete_document_document_by_pk/
delete_document_document(where:) cannot succeed on the real backend (an
AFTER DELETE trigger violates a NOT NULL constraint), and this mock has no
trigger/schema system of its own to discover that on its own — but it
doesn't need one, because the divergence is closed by an explicit
DECLARATION instead: HasuraAdapterOptions.deleteForbiddenTables (documented
in full under "deleteForbiddenTables — a delete that can never succeed"
above), which is exactly the same "declare it, don't guess it" shape
conflictKeys/schemas already use elsewhere in this adapter. A
delete against a table declared there now answers constraint-violation,
matching the real backend, instead of silently succeeding.
WebSocket subscriptions — hasuraWsAdapter reproduces captured reality
hasuraWsAdapter (the ./hasura subpath, alongside hasuraAdapter) serves
Hasura-shaped GraphQL subscriptions over graphql-ws
(ws.link('*<basePath>/v1/graphql')), taking HasuraWsAdapterOptions — a
Pick of basePath/tables/schemas/relations/sessions/scope out of
the same HasuraAdapterOptions hasuraAdapter takes, so one options object
can build both adapters and they will agree about what a table/column/
relation name means (same construction-time checks, same translation
functions). It runs every subscription through the exact runQueryRoot
hasuraAdapter's HTTP query path already uses — there is no second query
engine for subscriptions to drift from the query one.
This surface reproduces captured reality, not an invented protocol. Two
graphql-ws frame sequences (ws-subscription/ws-subscription-delta in
fixtures/hasura/) were captured against a production Hasura backend
(2026-08-11) and are replayed byte-for-sequence against
this adapter over a real socket in tests/hasura-fidelity.test.ts — see
"Fixture accounting, current" above. Two facts from those captures are worth
stating as facts, not inferences:
pingarrives BEFOREconnection_ack. The captured order isconnection_init→ping→pong→connection_ack→ping→pong→subscribe→next→complete.ws-protocol.ts's handshake state machine reproduces this structurally, not by coincidence:connection_initalways yields apingimmediately, andconnection_ackis only ever produced later, as part of handling the client's firstpong— so an ack can never be emitted before the first ping is.- The second
nextframe is a FULL RE-PUSH with new values, not a delta.ws-subscription-delta.json's filename suggests a diff; its actual second frame carries the completedocument_document_aggregateshape again, just withcount(andmax.updated_at) changed — that is how a live Hasura subscription actually behaves (a polling live-query under the hood, not a diff stream), andsubscription.tsreproduces that faithfully.ws-protocol.ts's ownNextFramedoc comment already carries this correction; the filename itself was never fixed, on purpose, so it stays discoverable as a documented gotcha rather than quietly renamed.
One invented shape sits inside this otherwise-reproduced half, and is called
out separately on purpose: the error frame's wire shape is a design
decision, not a captured fact. No ws-subscription*.json fixture ever
shows a real Hasura error message — this mock never observed one — so
ErrorFrame's { type: 'error', id, payload } shape only reuses two
already-evidenced pieces (graphql-ws's documented error message
vocabulary for id/type, and this package's own captured Hasura HTTP
error body for payload's shape); the combination, over this wire, is
unverified. It is produced only when a subscribe frame's query fails on
its very FIRST execution, before the store-change listener even exists.
The rest of the surface, in brief:
- The bearer credential is smuggled through
connection_init'spayload.headers.Authorization(case-insensitive key) — both captures show this shape, since a browser WebSocket handshake cannot carry a customAuthorizationheader the way an HTTP request can; this isgraphql-ws's own documented workaround, not this package's invention. - The session is resolved ONCE per connection, from
connection_init, and threaded into every subscription on that socket'svisible— mirroring how the HTTP path resolves a session once per request — so a subscription's isolation matches a query's isolation by construction. - No
Authorizationheader at all resolves to anonymous (undefinedsession, no error) — matching the HTTP path's own "no credential offered -> 200, empty lists" asymmetry (see "Not corroborated by any capture" under "fidelity sweep findings" above for why that HTTP-side behaviour itself is a mock choice, not a captured fact; the WS path inherits the same un-corroborated status for the exact same reason). - A credential that WAS presented but fails to resolve closes the
connection with
4403(Forbidden), not4401.connection_initcarries no subscriptionid, so there is noErrorFrameto attach a rejection to — closing the connection is the only mechanism available, and4403isgraphql-ws's ownPROTOCOL.md-documented code for authentication rejection atconnection_init, treated by its client as POTENTIALLY RECOVERABLE (retriable after refreshing the credential).4401means something different in this protocol — an operation frame (subscribe) arriving beforeconnection_ack— and that client hard-codes4401as FATAL/non-retriable; an earlier round of this sub-project used4401here by mistake and it was caught only by checkinggraphql-ws's actual source, not by inference from the spec name.
A re-push failure cannot reach the client — a recorded boundary, not a
bug to fix later. If recomputing a subscription's query throws after a
store change (not on the FIRST execution, which throws straight out and DOES
produce the ErrorFrame above), that throw lands in store.ts's
notify() listener loop, which catches every listener's failure, logs it,
and continues — the same isolation that keeps one broken subscription from
taking down the whole store'
