npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 msw

Quick 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:

  • pocketbaseAdapter opts in on every one of its own resolve call sites, matching real PocketBase's wire convention (no Bearer scheme) and the reusable createPocketbaseDataProvider that mimics it.
  • opsAdapter and authAdapter also opt in (mock-fidelity fix round, item 2) — but the two were decided on separate evidence, not by symmetry with pocketbaseAdapter. opsAdapter's own module doc comment says so itself: /api/marketplace/ops "has no live counterpart to snapshot, replay, or diff against." authAdapter carries no such self-declaration, so it was checked directly: its routes (/auth/login, /auth/me, /auth/switch-tenant, /auth/logout) were hand-written in commit 1560c29e with 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, always Bearer-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 bare session.token, pocketbaseAdapter already accepts it back, and an app wired against all three adapters through one SessionRegistry would otherwise have to remember which ones need the Bearer prefix re-added. A bare-token /auth/me also 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 globalRoles session with scope.warehouse pinned 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() and stamp() are untouched. A collection gated by requiresAxis is still absent for a globalRoles session 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 shipmentslist, 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:

  • login retires every live token that user currently holds — however it was minted (a previous login, or any number of prior switch-tenant calls). A fresh login is a new authentication event, and this is also the only thing that bounds the token map's growth, since switch-tenant can mint arbitrarily many tokens per user between logins.
  • switch-tenant retires 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.
  • logout retires 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 via switch-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 token

In 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:

  1. a table declared in tables with no matching schemas entry;
  2. a schemas entry for a table tables doesn't route;
  3. a relation (RelationConfig.collection) targeting a table tables doesn't route;
  4. a relation's foreignField not declared in the TARGET table's schema;
  5. a conflictKeys/deleteForbiddenTables entry naming a table tables doesn't route;
  6. a relation's localField, or a column a conflictKeys constraint lists, not declared in the relevant table's schema;
  7. a table's schemas entry declaring no id column at all — deriveBigintIdCollections (adapter.ts) calls schema.ts's idColumnOf for every routed table while deriving the internal bigint-id set, and idColumnOf throws unconditionally when a table's schema has no id entry. 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:

  1. a relation's localField and foreignField declaring DIFFERENT column types (bigint joined against text, 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;
  2. a column declaring generated on anything other than a timestamptz type — generated is only ever meaningful on a timestamptz column (schema.ts's own ColumnDef doc comment), and nothing checked that before, so a copy-paste error could silently stamp a non-timestamp column with a clock.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 a query document) — is access-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 id column 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). hasuraAdapter closes the gap at the HTTP boundary: declare the table's id column type: 'bigint' in HasuraAdapterOptions.schemas (the id-semantics sub-project's Task 4 removed the original, separately-opt-in bigintIdTables option and folded its behaviour into this one declaration — there is no other opt-in anymore), and both the response shape (id comes back numeric) and where: {id: {_eq: ...}}/{_in: ...} matching follow — the STORE now holds a bigint-declared table's id in its natural number type (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 answers data-exception, matching the real backend, instead of silently matching zero rows. A FRESH INSERT into a bigint-id table also gets a purely-numeric id (identity.ts's generateId/nextBigintId, one past the table's current highest numeric id) instead of store.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 throw data-exception trying 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 (or Store.insert-bypassed) row whose id is not a genuine number on a bigint-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's projectRow throws a plain Error the moment that row's id field is read back (any list/_by_pk/relation response that includes it). A path that only COMPARES against a target — where: {id: {_eq: ...}} in a query, or a mutation's where — never calls projectRow at all, so that same row is simply invisible there instead: the stored '5' is never === the coerced numeric target 5, 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_DIGITS never matches it, so projectRow serializes 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 a bigint-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-looking id.

    The Number.MAX_SAFE_INTEGER (253 - 1) ceiling, and what is and isn't handled at it:** a JS number — and therefore a bare JSON number, JSON.stringify's only way to emit one — cannot represent an integer past this magnitude exactly. identity.ts's nextBigintId (relocated out of mutation.ts by the id-semantics sub-project; same algorithm) uses BigInt internally and REFUSES (throws) rather than hand out a fresh id past this boundary — the write side of the ceiling. This throws a plain, loud Error rather than silently rounding — which, before fix round 3, is exactly what used to make two DIFFERENT rows serialize with the IDENTICAL projected id.

    On the READ side, select.ts no longer has a magnitude-specific backstop of its own: as of the id-semantics sub-project, ANY digit-only string id on a bigint-declared column throws unconditionally (see "Mis-seeded risk" above), whether or not that string happens to be past Number.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-sent id value that arrives as a bare JSON number (or a bare GraphQL Int literal in the query text, parsed by document.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's coerceBigintTarget now converts a numeric-string target to a number too (the store holds a bigint id in its natural number type end to end, per "The three id regimes" above), so a string target past Number.MAX_SAFE_INTEGER is refused with a loud Error, exactly like a bare-number target that far out — not silently passed through unconverted the way the pre-id-semantics coerceIdTarget used to.

  • 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 answers invalid-jwt, not a 200. hasuraAdapter makes the same distinction — an Authorization header present but unresolvable by SessionRegistry answers invalid-jwt, only when the adapter was actually given a sessions registry to begin with.

    Not corroborated by any capture, and stated separately on purpose: what happens with NO Authorization header 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 an Authorization header, 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_input and 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:

  • ping arrives BEFORE connection_ack. The captured order is connection_initpingpongconnection_ackpingpongsubscribenextcomplete. ws-protocol.ts's handshake state machine reproduces this structurally, not by coincidence: connection_init always yields a ping immediately, and connection_ack is only ever produced later, as part of handling the client's first pong — so an ack can never be emitted before the first ping is.
  • The second next frame 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 complete document_document_aggregate shape again, just with count (and max.updated_at) changed — that is how a live Hasura subscription actually behaves (a polling live-query under the hood, not a diff stream), and subscription.ts reproduces that faithfully. ws-protocol.ts's own NextFrame doc 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's payload.headers.Authorization (case-insensitive key) — both captures show this shape, since a browser WebSocket handshake cannot carry a custom Authorization header the way an HTTP request can; this is graphql-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's visible — mirroring how the HTTP path resolves a session once per request — so a subscription's isolation matches a query's isolation by construction.
  • No Authorization header at all resolves to anonymous (undefined session, 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), not 4401. connection_init carries no subscription id, so there is no ErrorFrame to attach a rejection to — closing the connection is the only mechanism available, and 4403 is graphql-ws's own PROTOCOL.md-documented code for authentication rejection at connection_init, treated by its client as POTENTIALLY RECOVERABLE (retriable after refreshing the credential). 4401 means something different in this protocol — an operation frame (subscribe) arriving before connection_ack — and that client hard-codes 4401 as FATAL/non-retriable; an earlier round of this sub-project used 4401 here by mistake and it was caught only by checking graphql-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'