{"_id":"@burojs/mock-kit","_rev":"2-7375583420d02341a829f1acad64a114","name":"@burojs/mock-kit","dist-tags":{"latest":"0.4.0"},"versions":{"0.2.0":{"name":"@burojs/mock-kit","version":"0.2.0","license":"MIT","_id":"@burojs/mock-kit@0.2.0","maintainers":[{"name":"maksimkuznetsov","email":"mak.kooz@gmail.com"}],"dist":{"shasum":"f58945d41b3ff4b75690415765fcf9aeaf73ea1a","tarball":"https://registry.npmjs.org/@burojs/mock-kit/-/mock-kit-0.2.0.tgz","fileCount":67,"integrity":"sha512-YapPF0tXDNqqmWNQSzbucOXIk1/91KLBHq69SD47JmcP0geiQHPHE+K/mxbVUlM+0QVqYR+weucMOlputwnuiw==","signatures":[{"sig":"MEYCIQCDR0C/pj0PvNyUjDeZWzZA9QhSkvdYNrfEntjZj/TiHgIhAP7NTEFBLlxHaFmFY91OPQIL8RBLwhzEJq+tsrf/yDfB","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":802555},"type":"module","_from":"file:burojs-mock-kit-0.2.0.tgz","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"./ops":{"import":{"types":"./dist/ops/index.d.ts","default":"./dist/ops/index.js"}},"./auth":{"import":{"types":"./dist/auth/index.d.ts","default":"./dist/auth/index.js"}},"./node":{"import":{"types":"./dist/node.d.ts","default":"./dist/node.js"}},"./hasura":{"import":{"types":"./dist/hasura/index.d.ts","default":"./dist/hasura/index.js"}},"./browser":{"import":{"types":"./dist/browser.d.ts","default":"./dist/browser.js"}},"./fixtures":{"import":{"types":"./dist/fixtures/index.d.ts","default":"./dist/fixtures/index.js"}},"./pocketbase":{"import":{"types":"./dist/pocketbase/index.d.ts","default":"./dist/pocketbase/index.js"}},"./package.json":"./package.json"},"scripts":{"dev":"tsup --watch","build":"tsup","test:e2e":"playwright test","typecheck":"tsc --noEmit","typecheck:tests":"tsc -p tsconfig.test.json && tsc -p e2e/tsconfig.json"},"_npmUser":{"name":"maksimkuznetsov","email":"mak.kooz@gmail.com"},"_resolved":"/tmp/0ca443c2fda8ae7ed7ca7951103a53ce/burojs-mock-kit-0.2.0.tgz","_integrity":"sha512-YapPF0tXDNqqmWNQSzbucOXIk1/91KLBHq69SD47JmcP0geiQHPHE+K/mxbVUlM+0QVqYR+weucMOlputwnuiw==","_npmVersion":"10.9.8","description":"Buro: MSW-backed offline mock backends for tests, e2e, and demos","directories":{},"_nodeVersion":"22.23.2","dependencies":{"msw":"^2.15.0","graphql":"^17.0.2"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.0","vite":"^6.0.0","vitest":"^2.1.0","typescript":"^5.5.0","@playwright/test":"^1.49.0","@burojs/tooling-tsconfig":"0.0.0"},"_npmOperationalInternal":{"tmp":"tmp/mock-kit_0.2.0_1786820376268_0.6061648631868655","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"_id":"@burojs/mock-kit@0.4.0","dist":{"shasum":"ff3dd2c22d0154fc9910508b3a5d760e1b34ed51","tarball":"https://registry.npmjs.org/@burojs/mock-kit/-/mock-kit-0.4.0.tgz","fileCount":73,"integrity":"sha512-04GFF6RrWspaE6RENzrFEQPeYm36PXGbVfrs+YM7l5AZHiTihKdJwuC7PzgfPKvRVMjjTrdWL4TNGdPJcRXIAg==","signatures":[{"sig":"MEUCIFNcoivccJM3iCFMsfZfroUob3wgD0miE+dK/A1e87HdAiEAsc94tQbx3ZktUAxnLnyTA9l0YIIlDgrWdRyEVYEDr2E=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIHHEsoSLBLPP8T1HZ5uOmJ3POJqhQTW8OBtWue39NZ5WAiAD+cDzPLWGS9X2b/XXRK4aSvBX8exNKaFwmYyHisPyMw=="}],"unpackedSize":783264},"name":"@burojs/mock-kit","type":"module","_from":"file:burojs-mock-kit-0.4.0.tgz","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"./ops":{"import":{"types":"./dist/ops/index.d.ts","default":"./dist/ops/index.js"}},"./auth":{"import":{"types":"./dist/auth/index.d.ts","default":"./dist/auth/index.js"}},"./node":{"import":{"types":"./dist/node.d.ts","default":"./dist/node.js"}},"./files":{"import":{"types":"./dist/files/index.d.ts","default":"./dist/files/index.js"}},"./hasura":{"import":{"types":"./dist/hasura/index.d.ts","default":"./dist/hasura/index.js"}},"./browser":{"import":{"types":"./dist/browser.d.ts","default":"./dist/browser.js"}},"./fixtures":{"import":{"types":"./dist/fixtures/index.d.ts","default":"./dist/fixtures/index.js"}},"./pocketbase":{"import":{"types":"./dist/pocketbase/index.d.ts","default":"./dist/pocketbase/index.js"}},"./package.json":"./package.json"},"license":"MIT","scripts":{"dev":"tsdown --watch","build":"tsdown","test:e2e":"playwright test","typecheck":"tsc --noEmit","typecheck:tests":"tsc -p tsconfig.test.json && tsc -p e2e/tsconfig.json"},"version":"0.4.0","_npmUser":{"name":"maksimkuznetsov","email":"mak.kooz@gmail.com"},"_resolved":"/tmp/ba71c64c66450a50d13eff1e53cfb1d6/burojs-mock-kit-0.4.0.tgz","_integrity":"sha512-04GFF6RrWspaE6RENzrFEQPeYm36PXGbVfrs+YM7l5AZHiTihKdJwuC7PzgfPKvRVMjjTrdWL4TNGdPJcRXIAg==","_npmVersion":"10.9.8","description":"Buro: MSW-backed offline mock backends for tests, e2e, and demos","directories":{},"maintainers":[{"name":"maksimkuznetsov","email":"mak.kooz@gmail.com"}],"_nodeVersion":"22.23.2","dependencies":{"msw":"^2.15.0","graphql":"^17.0.2"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vite":"^6.0.0","vitest":"^4.0.0","typescript":"^5.5.0","@types/node":"^22.0.0","@playwright/test":"^1.49.0","@burojs/tooling-tsconfig":"0.0.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/mock-kit_0.4.0_1790626381481_0.40490889269575203"}}},"time":{"created":"2026-08-15T18:59:36.132Z","modified":"2026-09-28T20:13:01.799Z","0.2.0":"2026-08-15T18:59:36.433Z","0.4.0":"2026-09-28T20:13:01.620Z"},"license":"MIT","description":"Buro: MSW-backed offline mock backends for tests, e2e, and demos","maintainers":[{"name":"maksimkuznetsov","email":"mak.kooz@gmail.com"}],"readme":"# @burojs/mock-kit\n\nMSW-backed offline mock backends for tests, e2e, and demos. The engine —\ndeterministic time/randomness, a persisted store, sessions, tenant/scope\nguarding, network fault injection — is domain-free; a domain pack (seed data,\n`tenantField` map, users) plugs into it. `pocketbaseAdapter` is the one\nbackend adapter that exists today; it and the engine ship from this same\npackage because nothing else has needed to be separate yet.\n\n## Install\n\n```bash\npnpm add -D @burojs/mock-kit msw\n```\n\n## Quick start\n\n```ts\nimport {\n  createMockKit,\n  createRng,\n  createScopeGuard,\n  defineDomain,\n  defineSessions,\n} from '@burojs/mock-kit';\nimport { pocketbaseAdapter } from '@burojs/mock-kit/pocketbase';\nimport { setupMockServer } from '@burojs/mock-kit/node'; // or './browser' + startMockWorker\n\nconst rng = createRng(1);\n\nconst domain = defineDomain(() => ({\n  orders: [{ id: 'o1', seller_id: 't1', total: 10 }],\n  categories: [{ id: 'c1', title: 'Shared' }], // no tenant field: global, visible to everyone\n}));\n\nconst sessions = defineSessions({\n  users: [\n    { id: 'u1', email: 'ana@example.com', password: 'pw', name: 'Ana', roles: [], tenantIds: ['t1'] },\n  ],\n  rng,\n});\n\nconst scope = createScopeGuard({ tenantField: { orders: 'seller_id' } });\n\nconst kit = createMockKit(domain, [pocketbaseAdapter(domain, { sessions, scope })], {\n  sessions,\n  rng, // pass the same instance defineSessions got, or reset() can't rewind it\n});\n\n// Node/vitest:\nconst server = setupMockServer(kit);\nserver.listen({ onUnhandledRequest: 'error' });\n// afterEach(() => kit.reset());\n\n// Browser (Vite/Next):\n// await startMockWorker(kit, { serviceWorker: { url: '/mockServiceWorker.js' } });\n```\n\n`defineDomain` seeds a `Store` and hydrates it from persistence (in-memory by\ndefault — pass `{ persistence: webStoragePersistence({ storage: localStorage }) }`\nfor the demo case). `createMockKit` wires the network-fault handler in front\nof every adapter and gives you back one `reset()` that puts the whole world —\nstore, sessions, faults, and the shared `Rng` — back to its seeded state.\n(A `createOperationRunner` a long operation's handler closes over is the one\nexception: `reset()` reaches it only if you list it in `opsAdapter`'s\n`runners` option — see \"Long operations have NO HTTP route\" under\n\"Operations RPC service\" below.)\n\n\"Sessions ... back to its seeded state\" means every live token is revoked,\nfull stop — there is no seeded-in session to come back to, since sessions\nare minted at runtime, not seeded data. If you signed in (by hand, or via\n`bootMockKit`'s launch-parameter `as`) before calling `reset()`, that token\nis dead afterward and a request made with it gets 401 — the world does NOT\ncome back \"still signed in as before.\" See \"Launch parameters\" below for\n`bootMockKit`'s `reissue()`, the seam for getting signed back in as the same\nuser after a reset without a full page reload.\n\n## Storages\n\n`memoryPersistence()` never survives a reload and never shares state — every\n`defineDomain` call gets its own isolated world. That is what tests want:\nparallel test files, or two `describe` blocks in the same file, must not see\neach other's writes.\n\n`webStoragePersistence({ storage, channel, key })` is the opposite: it\npersists to a `Storage` (`localStorage`/`sessionStorage`) and, if given a\n`BroadcastChannel`-shaped `channel`, keeps every open tab of the same origin\non the same world. That is what a public demo wants — a visitor who opens a\nsecond tab, or reloads, should not fall back to a blank seed.\n\nThe two are not just \"persisted vs. not\" — they change what a *second reader*\nsees. On save, `webStoragePersistence` writes to storage and posts on the\nchannel; a peer that receives that message re-reads storage and calls back\nwith the new snapshot, but never re-saves or re-posts what it just received.\nSkipping that step is deliberate: if the receiving tab re-broadcast the\nsnapshot it just adopted, the tab that sent it would receive its own write\nback as if it were external, re-adopt it, and re-broadcast again — two tabs\nwould ping-pong the same write at each other forever.\n\n`Store.hydrate()` (called once by `defineDomain`) merges rather than trusts\nstorage verbatim: for every collection the *seed* currently defines, it takes\nthe stored rows if storage has that key, or falls back to the seed's rows if\nit doesn't. A collection storage still has but the seed no longer defines is\ndropped. In other words: the store's *shape* always follows the code (seed),\nbut a collection's *content* — for anything the seed and storage agree exists\n— is the visitor's stored edits, not a fresh reseed. This is why a returning\nvisitor doesn't lose their state when a collection with unrelated changes\nships, but also doesn't get stuck on a schema an old build seeded.\n\n## Sessions and tenants\n\n`defineSessions({ users, rng })` gives you `login`, `issue`, `resolve`,\n`revoke`, and `reset`, plus the frozen `users` roster it defensively copied on\nthe way in (so nothing downstream — including the caller's own reference to\nthe array it passed — can mutate a live session or the roster after the\nfact).\n\nTokens are `mocktoken_N`, minted from `Rng.id('mocktoken')` — a plain\nper-registry counter, not a signed or seed-derived value. That is by design:\nthis is a test double, not a security boundary, and predictable tokens are\nuseful for e2e (\"set `Authorization: Bearer mocktoken_1` and skip the login\nUI\"), not a bug to fix.\n\n`resolve(request, options?)` requires the `Authorization: Bearer <token>`\nscheme by default; pass `{ allowBareToken: true }` (`ResolveOptions`,\nre-exported from the package root) to also accept a bare, unprefixed token.\nThree of the four adapters that call `resolve` opt in, each for its own\nreason:\n\n- `pocketbaseAdapter` opts in on every one of its own `resolve` call sites,\n  matching real PocketBase's wire convention (no Bearer scheme) and the\n  reusable `createPocketbaseDataProvider` that mimics it.\n- `opsAdapter` and `authAdapter` also opt in (mock-fidelity fix round, item\n  2) — but the two were decided on separate evidence, not by symmetry with\n  `pocketbaseAdapter`. `opsAdapter`'s own module doc comment says so itself:\n  `/api/marketplace/ops` \"has no live counterpart to snapshot, replay, or\n  diff against.\" `authAdapter` carries no such self-declaration, so it was\n  checked directly: its routes (`/auth/login`, `/auth/me`,\n  `/auth/switch-tenant`, `/auth/logout`) were hand-written in commit\n  `1560c29e` with no fixture and no cited real backend, and don't share a\n  path or response shape with the one real auth service this repository's\n  own tooling actually talks to (`/auth/v1/signin/email-password`,\n  `scripts/lib/hasura-session.mjs`, and the since-deleted `apps/playground`'s\n  Nhost client).\n  That real service is, moreover, never asked — anywhere in this repo —\n  whether it accepts a bare token on one of ITS OWN endpoints: every call\n  that carries its token hands that token straight to Hasura's\n  `/gql/v1/graphql`, always `Bearer`-prefixed, which is evidence about\n  Hasura, not about the auth service. So the honest statement is **no\n  evidence either way** for what a real auth service would do here, not \"a\n  real auth service accepts this too.\" With no real backend to be faithful\n  or unfaithful to, the decision to accept the bare token is a consistency\n  choice, not a fidelity one: the kit's own login routes already mint a bare\n  `session.token`, `pocketbaseAdapter` already accepts it back, and an app\n  wired against all three adapters through one `SessionRegistry` would\n  otherwise have to remember which ones need the `Bearer ` prefix re-added.\n  A bare-token `/auth/me` also fails in a way that is easy to misread — 401\n  reads exactly like \"your session expired\" to whatever is built against it,\n  not like a format mismatch.\n\n`hasuraAdapter` is the one adapter that does NOT opt in, and stays strict on\n`Bearer` — real Hasura's auth webhook requires that scheme, and a globally\nlenient `resolve` would trade one fidelity defect for another.\n\nCombined with `createScopeGuard`, `pocketbaseAdapter` answers **404, not\n403**, for a record the caller's session cannot see — whether that's a GET on\n`/records/:id`, or a PATCH/DELETE that resolves to \"not writable\". A 403\nwould confirm the record exists under someone else's tenant; a mock backend\nthat leaks existence teaches the wrong lesson to whatever's built against it,\nso this one always answers as if the record simply weren't there.\n\n`ScopeConfig.tenantField` maps *collection → the field holding its owning\ntenant*. A collection you don't list there is not \"scoped but permissive\" —\nit is **global**: `visible()` returns \"everyone can see everything in it,\"\nunconditionally, for every session including no session at all. There is no\nseparate flag for \"this collection is intentionally global\" — the omission\n*is* the declaration. That means a tenant-scoped collection accidentally left\nout of the map fails **open**, silently: every record in it becomes visible\nto every caller, with nothing at runtime to warn you. When you add a new\ntenant-scoped collection to your domain, add it to `tenantField` in the same\nchange — the correctness of every other entry says nothing about this one.\n\nWrites are guarded separately from reads: with a `scope` configured, a\n`pocketbaseAdapter` `create`/`patch`/`delete` from a request with **no\nresolvable session** is refused with **401**, even against a fully global\ncollection. An anonymous caller cannot plant an unstamped record or\nwaved-through edit just because the target happens to be visible to\neveryone. `opsAdapter` mirrors the same condition (a coarse, whole-operation\n`unauthorized`, also 401 — see \"Operations RPC service\" below).\n`hasuraAdapter` enforces the equivalent refusal on mutations too, but in its\nown vendor's shape, not a literal 401: an anonymous `insert`/`update`/`delete`\nroot answers HTTP 200 with a GraphQL `errors` envelope (`access-denied`,\n`\"field '<root>' not found in type: 'mutation_root'\"`) — the same status a\ngenuinely unrouted root produces, and the same \"no HTTP error status for a\npermissions refusal\" convention real Hasura mutations use. Do not assume\n\"401\" as a kit-wide constant; it is the REST-shaped adapters' convention,\nnot the GraphQL one.\n\n### `ScopeConfig.globalRoles` — a role that spans every tenant\n\n`tenantField`/`scopeFields` have no notion of *role* — they only ever compare\na record's tenant field against `session.tenantId`. That is not enough to\nexpress a \"platform operator\" style role that is legitimately supposed to see\nevery tenant's rows. `globalRoles` is the narrow escape hatch:\n\n```ts\ncreateScopeGuard({\n  tenantField: { orders: 'seller_id' },\n  globalRoles: ['platform_operator'],\n});\n```\n\nA session whose `roles` includes any name in this list has the **tenant**\ncomparison in `visible()` (and, through it, `owns()`, which always defers to\n`visible`) lifted entirely — it sees every tenant's rows in every\ntenant-scoped collection. The price is exactly that: **tenant isolation is\nswitched off for the roles you name here.** Treat the list itself as trusted\nmock configuration, never something derived from a request.\n\nTwo things this does **not** touch, on purpose:\n\n- **A pinned scope axis still applies.** A `globalRoles` session with\n  `scope.warehouse` pinned to one value sees that one warehouse across every\n  tenant, not every warehouse of every tenant — the role lifts the tenant\n  boundary, not a caller's own axis pin.\n- **`available()` and `stamp()` are untouched.** A collection gated by\n  `requiresAxis` is still absent for a `globalRoles` session with that axis\n  unpinned, and a record such a session creates is still stamped with *its\n  own* tenant — a global role does not mean the records it creates have no\n  owner.\n\n### Collection availability — a scope axis can make a collection not exist\n\n`ScopeConfig.tenantField`/`scopeFields` filter *which rows* of an\nalways-existing collection a caller sees. That is not enough to express\nsomething like \"with no warehouse selected, `shipments` and `receiving` do\nnot exist\" — an empty list still says the collection is there, just empty.\n`ScopeConfig.requiresAxis` is the other kind of gate:\n\n```ts\ncreateScopeGuard({\n  tenantField: { orders: 'seller_id', shipments: 'seller_id' },\n  scopeFields: { shipments: { warehouse: 'warehouse_id' } },\n  requiresAxis: { shipments: 'warehouse' }, // must be pinned, or the collection is absent\n});\n```\n\nWith that config, a session whose `scope.warehouse` is unset (or `null`, or\n`''` — the same \"whole of the axis\" values `visible()` already treats as\nequivalent) makes **every** `pocketbaseAdapter` route for `shipments` —\n`list`, `one`, `create`, `patch`, `delete`, and the file route — answer\n`404 { code: 404, message: 'Missing collection context.' }`, borrowing the\nmessage real PocketBase gives for a collection name it doesn't recognise at\nall. That borrowing is cosmetic, not a claim of indistinguishability: a\n`collection` name this mock's own store has genuinely never seen still comes\nback 200 with an empty page from `list`/`one`, not this 404 — \"no rows yet\"\nand \"this axis isn't pinned\" stay two different, checkable outcomes. Once\nthe axis is pinned to a concrete value (`?scope_warehouse=w1`, or\n`sessions.issue(userId, tenantId, { warehouse: 'w1' })`), the collection\nreappears and rows are filtered by `scopeFields` as usual.\n\nThe **404, not 401 or 403** choice here is deliberate, and consistent with\nthe rest of this section: an unauthenticated `create` against an\naxis-gated collection still gets 404, not the 401 an unauthenticated write\nagainst a real collection would get — a 401 would itself leak \"this route\nexists and requires auth,\" which is exactly the kind of existence leak the\ntenant-isolation 404s above already refuse to produce. `available()` is\nchecked before every other guard in `pocketbaseAdapter`, precisely so no\nother status code can leak past it. A collection with no `requiresAxis`\nentry is unaffected — this is opt-in per collection, same as `tenantField`.\n\nToken lifetime is a policy the auth adapter (and, when session-aware,\n`pocketbaseAdapter`'s own `auth-with-password`) enforces, not something\n`SessionRegistry` decides on its own — `issue`/`login` mint tokens with no TTL\nand no cap:\n\n- **`login` retires every live token that user currently holds** — however it\n  was minted (a previous login, or any number of prior `switch-tenant`\n  calls). A fresh login is a new authentication event, and this is also the\n  only thing that bounds the token map's growth, since `switch-tenant` can\n  mint arbitrarily many tokens per user between logins.\n- **`switch-tenant` retires nothing.** The pre-switch token stays valid on\n  purpose — a request already in flight against the old tenant, or a second\n  tab still open on it, keeps working. Its new token is only tracked so that\n  the *next* login for that user sweeps it up too.\n- **`logout` retires only the one token it was called with.** It does not\n  reach across and kill that user's other live sessions (a different\n  device's login, or a token picked up via `switch-tenant`).\n\n## Determinism\n\n`Math.random()` and `Date.now()` are banned from the kit and from domain\npacks — either one makes a seed meaningless, since two runs of the same seed\ncould then sort, paginate, or timestamp differently. `createRng(seed)` (a\nsmall mulberry32 generator) and `createClock(startIso, stepMs)` are the only\nsanctioned sources of randomness and time; use them in every seed factory and\nevery place that would otherwise reach for `Math.random`/`Date.now`.\n\nBoth expose `reset()`. `Rng.reset()` restores the seed's initial internal\nstate **and** clears every `id()` prefix counter — both halves matter,\nbecause `store.reseed()` alone puts the store's rows back but has no idea an\n`Rng` exists, so without also resetting it, generated ids and any\nseed-derived content drift further from the seed on every reset. Pass the\n*same* `Rng` instance to `defineSessions({ rng })` and to `createMockKit(...,\n{ rng })` (and to your seed factory, if it generates anything) so\n`kit.reset()` can rewind all of it together — `kit.reset()` only resets the\nrng if you gave it one.\n\n### `PocketbaseAdapterOptions.ids` — a caller-supplied id factory\n\n`pocketbaseAdapter`'s `create` handler otherwise always mints a new record's\nid from `store.nextId(collection)`, which produces `<collection>_<n>` —\nreadable, but not the shape a real backend hands out (a uuid, most commonly).\nWhen some other part of the mock — a relation, a fixture, a route a demo\nscript hits by a known id — needs to predict or match that shape, `ids` lets\nyou override it per collection:\n\n```ts\npocketbaseAdapter(domain, {\n  ids: { order: () => crypto.randomUUID() },\n});\n```\n\nThe price: **this option does not check for collisions.** `store.insert`\nkeys rows by id, so a factory that returns an id already in use silently\noverwrites that row rather than erroring — uniqueness is entirely the\nfactory's own responsibility. The factory is called exactly once per create;\nif it draws from a shared, seed-shared `Rng` (the deterministic-id\nconvention described above), a second call would shift that `Rng`'s stream\nfor everything drawn from it afterwards. A collection with no entry in `ids`\nkeeps getting `store.nextId(collection)`, unchanged — opt-in per collection,\nsame as `tenantField`/`requiresAxis`.\n\n## Network control\n\n`createNetworkProfile()` gives you `setLatency(ms)` and `failNext({ match?,\nstatus, body?, times? })`; `networkHandler(profile, { prefix? })` is an MSW\nhandler that must be registered before any adapter (`createMockKit` does this\nfor you when you pass `network`, and forwards `networkPrefix` to it). It only\nlooks at requests whose path contains `prefix` (default `/api`) — everything\nelse, e.g. the page's own JS/CSS/fonts/images, falls straight through\nuntouched. For a matching request, it applies the configured latency and then\n— only if a fault rule matches — answers with it instead of letting the\nrequest reach the adapter.\n\nLatency runs on **both** paths, fault or not: `setLatency(300)` +\n`failNext({ status: 500 })` together is exactly how you test \"spinner, then\nerror\" — if latency only applied to the happy path, that combination could\nnever be exercised, since the fault would resolve instantly.\n\n`failNext`'s `match` is a plain substring test against the request URL, not a\nroute pattern — good enough for a handful of deliberately chosen URLs in a\ntest, but a short or numeric `match` can coincidentally hit more than you\nmeant (a rule for `/api/orders` also matches `/api/orders/42`). `times`\ndefaults to 1 and consumes down to removal; `times: 0` registers nothing at\nall, rather than a rule that silently fires once anyway.\n\nIn the browser, `startMockWorker(kit, { network })` attaches\n`window.__mockControl` (`reset`, `setLatency`, `failNext`) *before* awaiting\nservice-worker activation, so an e2e test can arm a fault or reseed the world\nimmediately — none of those calls touch the worker itself, only the\nkit/network state, so there's no reason to make them wait.\n\nOne MSW caveat worth knowing before reaching for it: MSW intercepts HTTP\nthrough the service worker (or, in Node, through interception of `fetch`),\nbut it intercepts WebSocket traffic by replacing the global `WebSocket`\nclass — a different mechanism, invisible in the browser's Network tab, and\nverified to be the SAME mechanism in Node and browser alike (`setupServer`\nand `setupWorker` both sit on `@mswjs/interceptors/WebSocket`; what differs\nis `setupWorker` refusing to run under Node at all, and the service-worker\nregistration path). `hasuraWsAdapter` (see \"WebSocket subscriptions\" below)\nis the one adapter in this package that speaks it.\n\n## Launch parameters\n\n`parseMockOptions(search)` reads a `URLSearchParams`-shaped string (with or\nwithout a leading `?`) into a `MockStartOptions`:\n\n| param        | meaning                                                                 | default    |\n| ------------ | ------------------------------------------------------------------------ | ---------- |\n| `mock`       | `local` selects `webStoragePersistence`; anything else (or absent) is in-memory | `memory`   |\n| `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` |\n| `as`         | email of the user to start signed in as                                | anonymous |\n| `tenant`     | tenant id to start on, when `as` belongs to more than one                | first tenant for `as` |\n| `scope_*`    | any `scope_<axis>=<value>` param becomes `scope[axis] = value`           | `{}`       |\n\nBy itself, `parseMockOptions` is only a parser — it does not build anything.\n`bootMockKit(options)` is what actually consumes it: one call that turns a\nlaunch query string into a running, already-signed-in kit — persistence\nselected, `Rng` seeded, sessions built, and (if `as` was given) a session\nissued and ready, all before your app renders a single pixel. This is the\npiece that makes an e2e run skip the login UI entirely, per spec §9.2: one\nspec exercises the real login form, every other spec loads a URL and is\nalready signed in.\n\n```ts\nimport { bootMockKit } from '@burojs/mock-kit';\nimport { pocketbaseAdapter } from '@burojs/mock-kit/pocketbase';\nimport { startMockWorker } from '@burojs/mock-kit/browser';\n\nconst USERS = [\n  {\n    id: 'u1',\n    email: 'ana@example.com',\n    password: 'pw',\n    name: 'Ana',\n    roles: ['merchant_admin'],\n    tenantIds: ['t1', 't2'],\n  },\n];\n\nconst boot = bootMockKit({\n  seed: (rng) => ({\n    orders: [{ id: rng.id('order'), seller_id: 't1', total: 10 }],\n    shipments: [{ id: rng.id('shipment'), seller_id: 't1', warehouse_id: 'w1', label: 'box' }],\n  }),\n  users: USERS,\n  scope: {\n    tenantField: { orders: 'seller_id', shipments: 'seller_id' },\n    scopeFields: { shipments: { warehouse: 'warehouse_id' } },\n    requiresAxis: { shipments: 'warehouse' },\n  },\n  // Only needed if a launch URL might ask for `mock=local`:\n  webStorage: { storage: localStorage, channel: new BroadcastChannel('buro-mock') },\n  adapters: ({ domain, sessions, scope, clock }) => [\n    pocketbaseAdapter(domain, { sessions, scope, clock }),\n  ],\n});\n\nawait startMockWorker(boot.kit, {\n  serviceWorker: { url: '/mockServiceWorker.js' },\n  session: boot.session, // reachable at window.__mockControl.session\n  reissue: boot.reissue, // reachable at window.__mockControl.reissueSession()\n});\n\n// The app's own auth bootstrap reads `boot.session`/`boot.token` (or, once\n// the worker is up, `window.__mockControl.session?.token`) instead of\n// showing a login screen — for a page loaded with no `as` param, both are\n// `undefined` and the app boots anonymous, exactly as before this existed.\n```\n\nA page loaded as `?as=ana@example.com&tenant=t2&scope_warehouse=w1` boots\nwith `boot.session` already issued for Ana, tenant `t2`, pinned to warehouse\n`w1` — no fetch to `/auth/login`, no form. A page loaded with `?as=` set to\nan email with no matching `MockUser`, or a `tenant` that user does not\nbelong to, does not fall back to anonymous: `bootMockKit` **throws**,\nimmediately, at boot — a test that believes it is signed in as someone it is\nnot is a worse failure mode than one that visibly isn't signed in at all. A\npage loaded as `?mock=local` with no `webStorage` option configured throws\nthe same way, rather than silently downgrading to in-memory (which would\nquietly break the \"second tab sees the same world\" guarantee `mock=local`\npromises).\n\n**`boot.token` does not survive `boot.kit.reset()`.** `reset()` revokes\nevery live session — see the caveat under \"Quick start\" above — and the\nboot-issued one is not special-cased to survive it: a test that resets\nspecifically to check a signed-out world must not find itself silently\nstill signed in just because it happened to boot via `as`. This is the exact\ne2e shape spec §9.2 runs (boot signed in → run a spec → reset → run the\nnext spec), so `bootMockKit` gives it an explicit seam instead of leaving it\nto a page reload: `boot.reissue()` re-runs the same `as`/`tenant`/`scope_*`\nresolution and returns a fresh session (`undefined`, harmlessly, for a boot\nthat was anonymous to begin with). It does **not** update `boot.session`/\n`boot.token` in place — those stay as they were at boot — so use its return\nvalue going forward:\n\n```ts\nboot.kit.reset();\nconst fresh = boot.reissue(); // same user, same tenant, same scope — new token\n```\n\nIn the browser, pass `reissue: boot.reissue` to `startMockWorker` (as in the\nsnippet above) and call `window.__mockControl.reissueSession()` after\n`window.__mockControl.reset()` — that variant *does* update\n`__mockControl.session` in place, so re-reading it afterward sees the fresh\ntoken.\n\n`bootMockKit`'s `seed` receives the boot's own `Rng` (built from `seed`, or\n`1`) — close over it for any rng-derived id/content, the same way you would\nwhen *not* using `bootMockKit`; it drives both the store and\n`sessions.issue`'s tokens, and `boot.kit.reset()` rewinds it, so \"same seed,\nsame world\" holds for the whole assembled kit, not just the store.\n\n## Fidelity boundaries\n\nToday this package ships two backend API adapters: `pocketbaseAdapter` and\n`hasuraAdapter`. Four further surfaces are enumerated below — `authAdapter`,\n`hasuraWsAdapter`, `opsAdapter` and `objectStoreAdapter`/`fileRoute` — each\nstamped with where its behaviour comes from. **Every surface this package ships\nmust appear in this section with that stamp**; a designed surface recorded only\nin a changeset (consumed and deleted at release) or in an SDD report (not\nshipped at all) is not recorded.\n\n`pocketbaseAdapter` implements a subset of PocketBase's HTTP API — records\nCRUD, `filter`/`sort`/pagination on `GET /api/collections/:collection/records`,\n`auth-with-password`, and placeholder responses for file fields. Nothing\nbeyond that: no PocketBase realtime/SSE, no batch API, no relation\nexpansion (`expand`), no field-level validation rules.\n\n`authAdapter` (the `./auth` subpath) is a separate, PocketBase-independent\nlogin/me/logout/switch-tenant surface over the same `SessionRegistry` —\nuseful when the app being mocked isn't talking to PocketBase at all.\n\nTwo more surfaces exist beyond these three, added by the SP1c sub-project —\n`hasuraWsAdapter` (WebSocket subscriptions, \"WebSocket subscriptions\" below)\nand `opsAdapter`/`createOperationRunner` (a neutral RPC surface, \"Operations\nRPC service\" below). **The two have deliberately different epistemic\nstatus, and it matters which is which:** `hasuraWsAdapter` REPRODUCES\ncaptured reality — two real `graphql-ws` frame sequences were captured\nagainst the live Hasura gateway, and every claim below about handshake\nordering or re-push shape is traceable to one of those two fixtures. The ops\nservice is DESIGNED, not captured: `/api/marketplace/ops` has no live\ncounterpart anywhere, so nothing about its routes, error envelope, status\ncodes, or check order was ever observed — every one of those is a choice\nthis package made, recorded as such at the point it's made (and again\nbelow), not a fact discovered by capturing a real backend.\n\n**A sixth surface, `objectStoreAdapter`/`fileRoute` (the `./files` subpath,\nadded by SP4 task 6) — DESIGNED, not captured, in every part.** It is the\nkey-addressed public bucket that sits BESIDE a GraphQL API, because Hasura\nserves no bytes and a Hasura-backed stack keeps its objects in S3 / nhost /\nSupabase storage. **No capture backs any of it**, and there is no fixture\ncorpus for an object store in this repository at all: the 404 envelope\n(`{\"message\": \"The specified key does not exist.\"}` — S3's WORDING borrowed for\nits shape, not an S3 capture, and not S3's XML error document either), the\n`content-type: image/svg+xml` / `cache-control: no-cache` headers, the\nsynthetic SVG body, the `/files` default base path, and the decision that an\nabsent `has` predicate serves every key are each a choice this package made. Treat them as this mock's convention, never as\nevidence about what a real bucket answers — the same standing this README gives\n`opsAdapter` above, and the same standing `fixtures/README.md`'s \"Operators\nimplemented with no capture behind them\" gives the eight designed `where`\noperators. `fileRoute` is now the only place in this package that produces file\nbytes (`pocketbaseAdapter`'s route composes it), so the note above applies to\nthe PocketBase file mount too: its URL SHAPE follows PocketBase, its BODY is\nthis same synthetic placeholder.\n\n### Hasura adapter — what it implements\n\n`hasuraAdapter` (the `./hasura` subpath) serves one Hasura-shaped endpoint,\n`POST <basePath>/v1/graphql`, over an ALLOW-LIST schema the caller declares\nvia `HasuraAdapterOptions.tables` (Hasura root name → store collection) AND\n`HasuraAdapterOptions.schemas` (Hasura root name → declared column types) —\n`schemas` is MANDATORY, and every option that names a table\n(`relations`/`conflictKeys`/`deleteForbiddenTables`) is keyed by the same\nHasura root name `tables` uses, not the store collection name. A table\ndeclared in one of `tables`/`schemas` but not the other, a relation naming an\nunknown table/column, or a `conflictKeys`/`deleteForbiddenTables` entry\nnaming an unknown table — all throw at CONSTRUCTION time, before the first\nrequest, rather than producing a confusing (or silently wrong) answer on\nwhichever request happens to touch the broken part first. Everything below\nwas proven against real captured fixtures, not written from the\nGraphQL/Hasura spec from memory — anything a fixture never exercised is\nrefused (`validation-failed`), not guessed at, because a mock that guesses a\npermissive answer teaches the wrong lesson to whatever is built against it.\n**`fixtures/README.md` is the source of every fact in this section and the\nnext** — read it for the fixture-by-fixture evidence behind each claim here.\n\n### Table schema — mandatory, one declared type per column\n\nEvery table named in `HasuraAdapterOptions.tables` must have a matching\nentry in `HasuraAdapterOptions.schemas`, and every `schemas` entry must name\na table `tables` actually routes — both directions are checked at\nconstruction time (see \"Construction-time checks\" below). A `TableSchema` is\n`{ columns: Record<string, ColumnDef> }`; each `ColumnDef` is `{ type,\nnullable?, generated? }`. `type` is one of `'bigint' | 'uuid' | 'text' |\n'timestamptz' | 'boolean' | 'jsonb'` — five of the six are the vocabulary\nSP0's captured fixtures actually exercise: `bigint` (integers), `timestamptz`\n(timestamps), `boolean`, `text` (plain, possibly-`null`-nullable strings),\nand `jsonb` (`document_document`'s `head`/`meta`/`error`/`system_meta`,\n`reference_freeform_templates.config`, `document_document_data.data` — see\n`tests/hasura-fidelity.test.ts`'s own schema declarations for each). The\nsixth, `uuid`, is the one type the live capture never needed (none of the\nfive tables it covers has a uuid-keyed id) but this package's own test\ndomain does, to prove the uuid id path end to end rather than leave it\nunreachable.\n\n`generated` (`'created' | 'updated'`, meaningful only on a `timestamptz`\ncolumn — and, since the final id-semantics fix-wave, ENFORCED: declaring it\non any other column type throws at construction, see \"Construction-time\nchecks\" below) is what replaced the removed `timestamps` option — see\n\"Removed options\" below. **`nullable` (default `true`) is DOCUMENTARY ONLY: nothing\nin this adapter reads it.** The design intended it to fill an omitted column\nwith `null` on insert; that was never implemented, and an insert that omits\na non-null column is not refused either — declaring `nullable: false`\nrecords intent for a human reader, not an enforced rule. See \"Known,\ndeliberate precision boundaries\" below.\n\n**The three id regimes.** Every table's `id` column behaves one of three\nways, chosen entirely by its declared `type`:\n\n| `id` column `type` | Comparison | Wire shape | Fresh-insert id | A target that can't match |\n| --- | --- | --- | --- | --- |\n| `bigint` | bare `===`, both sides `number` | JSON number | `max(existing numeric ids) + 1`, refuses past `Number.MAX_SAFE_INTEGER` | `data-exception` |\n| `uuid` | bare `===`, both sides `string` | JSON string | random, canonically-shaped v4 uuid drawn from the adapter's configured `rng` | `data-exception` |\n| `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 |\n\nThe store (`store.ts`) now holds an id in whichever of these types the\nadapter gave it (`Rec['id']` is `string | number`), and every comparison\nsite in the package — `where` in a query, `where` in a mutation, all three\n`_by_pk` paths, `on_conflict`'s conflict-column matching, and the FK join a\nrelation performs — is a bare `===` against a value already agreed on type.\nThe stored value itself is NEVER coerced anywhere; the one thing that still\ngets parsed is the incoming TARGET (a `where`/`pk_columns`/`_by_pk`/\n`on_conflict.object.id` argument), through `identity.ts`'s `coerceIdTarget`,\nresolved to the table's DECLARED `id` column type (`idColumnOf`) at every\none of those SIX sites — `query.ts`'s `buildPredicate`/`runByPk` and\n`mutation.ts`'s `buildEqPredicate`/both `_by_pk` mutation paths/\n`findConflictRow` all dispatch the same way now. Before the final\nid-semantics fix-wave this dispatch was gated on a `bigint`-only boolean\n(`bigintIdCollections?.has(...)`) on the READ side alone, and not gated at\nall — no coercion, ever — on any of the other five: a `bigint` `_eq: \"101\"`\n(a digit-only STRING target) matched on read but not on write, a\ncase-different `uuid` target (Postgres itself folds `uuid` case) never\nmatched anywhere, and — the damaging direction, caught by a re-review after\nthe rest were closed — `on_conflict`'s own conflict-column matching\n(`findConflictRow`) silently failed to recognize a REAL conflicting row\nwhenever its `id` target needed coercion (a digit-string against `bigint`,\na case-different `uuid`), so the insert fell through to a fresh row: a\nDUPLICATE sharing the same logical id, written silently, HTTP 200, no\nerror — worse than the read-side gap, which only ever answered an\nover-cautious empty list. Dispatching on the table's actual declared type,\nat every site, closed all of this at once — see\n`tests/hasura-id-types.test.ts`'s `uuid case-folding and malformed-target\nrefusal, on every id-comparison path` describe block for the `where`/\n`_by_pk`/write-`where` proof, and the dedicated\n`tests/hasura-mutation-onconflict-id.test.ts` for the `on_conflict` proof\n(a bigint digit-string target and a case-different uuid target each\nupdating the real conflicting row instead of inserting a duplicate).\n\n**Removed options — `bigintIdTables` and `timestamps` no longer exist** on\n`HasuraAdapterOptions`. Both were narrower, separately-opt-in predecessors of\nwhat `schemas` now expresses in one place: `bigintIdTables` (a `Set` of\ntable names) is now simply `type: 'bigint'` on that table's `id` column;\n`timestamps` (a per-table `{created?, updated?}` column-name map) is now\n`generated: 'created'`/`'updated'` on the relevant `timestamptz` column.\nThere is exactly **one option keyspace** now: every option that names a\ntable — `tables`, `schemas`, `relations`, `conflictKeys`,\n`deleteForbiddenTables` — is keyed by the Hasura root name `tables` itself\nuses, never the store collection name.\n\n**Construction-time checks.** `hasuraAdapter()` throws before the first\nrequest is ever served, for any of:\n\n1. a table declared in `tables` with no matching `schemas` entry;\n2. a `schemas` entry for a table `tables` doesn't route;\n3. a relation (`RelationConfig.collection`) targeting a table `tables`\n   doesn't route;\n4. a relation's `foreignField` not declared in the TARGET table's schema;\n5. a `conflictKeys`/`deleteForbiddenTables` entry naming a table `tables`\n   doesn't route;\n6. a relation's `localField`, or a column a `conflictKeys` constraint lists,\n   not declared in the relevant table's schema;\n7. a table's `schemas` entry declaring no `id` column at all —\n   `deriveBigintIdCollections` (`adapter.ts`) calls `schema.ts`'s\n   `idColumnOf` for every routed table while deriving the internal bigint-id\n   set, and `idColumnOf` throws unconditionally when a table's schema has no\n   `id` entry. This runs at construction time, before the request handler is\n   even assembled, exactly like checks 1-6 — not merely on the first request\n   that happens to touch the table.\n\n(`adapter.ts` runs two more checks alongside these seven, both added by\nreview rather than in the original plan: a relation declared for a table\nthat is itself not in `tables` at all — closing an edge the seven above\nonly caught incidentally, and only when that table's relation set happened\nto be non-empty — and, from the final id-semantics fix-wave:\n\n8. a relation's `localField` and `foreignField` declaring DIFFERENT column\n   types (`bigint` joined against `text`, for example) — checks 4/6 above\n   only confirm both columns EXIST, never that they agree on type, and a\n   type mismatch constructs cleanly today only to resolve to a\n   permanently-`null`/always-empty relation for every row, with no error\n   anywhere;\n9. a column declaring `generated` on anything other than a `timestamptz`\n   type — `generated` is only ever meaningful on a `timestamptz` column\n   (`schema.ts`'s own `ColumnDef` doc comment), and nothing checked that\n   before, so a copy-paste error could silently stamp a non-timestamp column\n   with a `clock.now()` ISO string.\n\nThere is also a TENTH throw reachable in `hasuraAdapter()`'s construction\nbody that this numbered list, for a while, omitted:\n`flattenConflictKeys`'s duplicate-constraint-name throw (two different\ntables declaring the same `on_conflict` constraint name) — real, already\ntested (`tests/hasura-adapter-schema.test.ts`'s \"conflictKeys constraint\nname collision across two different tables throws at construction\"), just\nnever catalogued here. `columnOf`'s own \"no table declared\" throw is\ngenuinely UNREACHABLE from `hasuraAdapter()` once checks 1-3 have run — it\nis listed here only because grepping the source finds an eleventh `throw`\nthat looks reachable and isn't; the ten above are the complete, provable\ncatalog.)\n\n**Root fields, one set per table declared in `tables`:**\n- Reads: `<table>` (list), `<table>_by_pk`, `<table>_aggregate`.\n- Writes: `insert_<table>_one`, `update_<table>_by_pk`, `update_<table>`,\n  `delete_<table>_by_pk`, `delete_<table>`.\n- Any other root name — a table never declared in `tables`, or a name using\n  the wrong operation kind's shape (e.g. a mutation-shaped root inside a\n  `query` document) — is `access-denied`, never a 404 or a quietly empty\n  result.\n\n**List (`<table>`) arguments:** `where`, `order_by`, `limit`, `offset`,\n`distinct_on`. `where` operators come in two PROVENANCE groups, combined with\nimplicit AND across fields:\n\n- **Captured** — `_eq`, `_in`, `_is_null`. Every fixture in\n  `fixtures/hasura/` that carries a `where` uses only these.\n- **Designed, not captured** (SP4 task 1) — `_neq`, `_nin`, `_gt`, `_gte`,\n  `_lt`, `_lte`, `_like`, `_ilike`. Implemented from Postgres's and Hasura's\n  documented semantics because `@burojs/hasura`'s `OP_MAP` emits them and\n  nothing could filter a Hasura-served resource without them; **no capture\n  backs any of them**. Ranges and `_neq`/`_nin` follow SQL three-valued logic\n  (a NULL column value satisfies none of them, and a NULL inside a `_nin`\n  array makes the predicate unsatisfiable for every row); `_like`/`_ilike`\n  are anchored to the whole value with `%`/`_` wildcards and `\\` as the\n  escape character. `_like`/`_ilike` against a non-`text` column, and a range\n  against `jsonb`, are refused rather than answered — a real Postgres has no\n  such operator. What is NOT claimed: `_ilike`'s case folding beyond ASCII,\n  and `text` ordering under a real collation. See\n  `fixtures/README.md`'s \"Operators implemented with no capture behind them\".\n\nAny other operator — `_regex`, `_similar`, `_nlike`, … — still throws.\n`where` also supports the boolean combinators `_and`/`_or`/`_not` (SP3a task\n1, captured live against a production Hasura backend —\n`fixtures/hasura/where-*.json`, see `fixtures/README.md`): `_and`/`_or` take\nan array of nested `where` fragments and intersect/union them, `_not` takes a\nsingle fragment and negates it, they nest inside each other and beside a\nplain field (ANDing together with it the same way two plain fields do), and\nthe empty-array forms are pinned to real backend behaviour: `_and: []` is\nvacuously TRUE (matches every row), `_or: []` is vacuously FALSE (matches\nnone). A combinator key is recognized before a top-level `where` key is ever\nread as a field name; any other `_`-prefixed key still throws. `order_by`\naccepts a plain column or one level of\nrelation (`{location: {name: asc}}`); with none given, the default is\nascending `id` — matching the live backend's own default order, not\nMap-iteration order (see \"`_in` does not preserve order\" below for the\nfixture that proves it). `distinct_on` only as a single bare field name\n(always paired with an `order_by`, matching how the live backend actually\nuses it) — the compound multi-field form is refused. `<table>_aggregate`\nonly takes `where`; its `aggregate { }` selection only supports `count` and\n`max { <field> }` — no `min`/`avg`/`sum`, no sibling `nodes`.\n\n**`<table>_by_pk`** takes only a bare `id` (string or number); a miss\nreturns `null`, never an error (`by-pk-missing.json`).\n\n**Mutations:** `insert_<table>_one` takes `object` and an optional\n`on_conflict: {constraint, update_columns}` — a `constraint` name must be\ndeclared in `HasuraAdapterOptions.conflictKeys` first (keyed by Hasura table\nname, then by constraint name: `{ [hasuraTable]: { [constraint]: columns } }`\n— construction-time checks confirm the table is routed and every listed\ncolumn is declared in that table's `schemas` entry), since this mock has no\nPostgres catalog to resolve a constraint name to columns on its own. A\ntable's `id` column follows whatever `schemas` declares for it: `bigint` gets\na generated sequential id that serializes as a number over the wire (a\nclient-supplied `object.id` is never honoured on a `bigint` table — `insertId`\nalways calls `generateId` for it, the same as `uuid` — see \"fidelity sweep\nfindings\" below), `uuid` gets a random id drawn from the adapter's configured\n`rng`, and `text` REQUIRES a client-supplied id (the mock never invents a\nnatural key — an insert with no `id` on a `text`-id table answers\n`validation-failed`).\n`update_<table>_by_pk` takes `pk_columns` (a bare `{id}` only — a compound\nkey is refused) and `_set`. `update_<table>`/`delete_<table>` take\n`where`/`_set` or `where` respectively, and `where` on the WRITE side only\never supports `_eq` per leaf field — no `_in`/`_is_null` there, since no\ncaptured write fixture uses them, and none of the eight designed read-side\noperators either (SP4 task 1 widened the READ side only; the write side is\nunchanged, and `tests/hasura-mutation.test.ts` pins that a `_gt` in a write\n`where` is still refused). The `_and`/`_or`/`_not` combinators ARE\nsupported on the write side too (same task), with the same semantics as the\nread side — a write's `where` is the same `<table>_bool_exp` GraphQL input\ntype as a read's, so the boolean-combination rule the read-side capture\nproved applies unchanged; only the per-leaf operator restriction (`_eq`\nonly) differs between the two, and that restriction was already true before\nthis task. `delete_<table>_by_pk` takes a bare `id`.\n\n**Schema authority on writes now covers `insert`, `_set`, and\n`on_conflict.update_columns` alike.** `insert_<table>_one`'s `object` is\nvalidated against the table's DECLARED `schemas` entry (`mutation.ts`'s\n`knownColumnsOf`/`assertKnownColumns`): an unknown column always throws\n`validation-failed`, on a populated table AND on a genuinely empty one — the\nschema's column list exists whether or not any row has been written yet,\nclosing what used to be a real gap (\"an insert into a table with zero rows\ncan't be checked, so it silently accepts anything\"). `update_<table>_by_pk`/\n`update_<table>`'s `_set` (`buildSetPatch`) and `on_conflict.update_columns`\n(`applyOnConflictUpdate`) now consult the SAME `knownColumnsOf` — the final\nid-semantics fix-wave's Critical 2 fix — falling back to the row-derived\ncheck only when `table` itself has no `schemas` entry at all. Before that\nfix, both paths validated against the TARGET ROW's own observed keys, which\nhad a damaging failure mode: a DECLARED, nullable column genuinely absent\nfrom a hand-seeded row (this mock's whole purpose is running against\nhand-written seeds, not only rows the adapter itself inserted) made a\nlegitimate `_set`/`on_conflict.update_columns` write to that column throw\n`Unknown column`, even though the identical column was readable (serializes\n`null`) and insertable. There is no longer a gap between what a fresh\n`insert` accepts and what a later `update`/`on_conflict` on the same table\naccepts.\n\n**Relations:** one level of `object` (resolves to a row or `null`) or\n`array` (resolves to a list) relation, declared explicitly per Hasura table\nname via `HasuraAdapterOptions.relations` — never inferred from\nforeign-key-shaped column names. Both the declaring table (the outer key)\nand the related table (`RelationConfig.collection`) are Hasura table names,\nnot store collection names; construction-time checks confirm the related\ntable is routed, that `localField`/`foreignField` are declared columns of\nthe declaring/related table's `schemas` entries, respectively, and — since\nthe final id-semantics fix-wave — that the two declare the SAME column type\n(a `bigint`-vs-`text` join constructs cleanly otherwise, then silently\nresolves to a permanently-`null`/always-empty relation for every row). An object\nrelation accepts no arguments at all; an array relation accepts only\n`limit`/`order_by` (no `where`/`offset`/`distinct_on` nested inside a\nrelation selection — no fixture ever does that).\n\n**Not implemented at all**, and refused (`validation-failed`) rather than\nsilently answered: GraphQL subscriptions over this adapter (HTTP POST only —\nsee the WebSocket paragraph above); fragments and directives in the query\ndocument; any `where` operator beyond the ones listed above (`_and`/`_or`/\n`_not` ARE implemented now — see the list-arguments paragraph above);\n`min`/`avg`/`sum` aggregates or a sibling `nodes` on an aggregate root; a\ncompound `pk_columns` or multi-field `distinct_on`; and any argument on a\nnested relation beyond `limit`/`order_by` on an array relation.\n\nAlso **not implemented, but silently so rather than refused** — transactional\natomicity across multiple mutation roots in one document. A throw on a later\nroot does NOT roll back writes already committed by earlier roots in the\nsame document, so a client that correctly handles the error can still be\nleft with a partially-applied write. See \"Multi-root mutations are not\natomic\" below.\n\n### Error shape — always HTTP 200, distinguished only by `extensions.code`\n\nEvery GraphQL-level failure this adapter can produce — a parse error, an\nunroutable root, a refused argument/operator, a genuine constraint\nviolation, an invalid credential — comes back as **HTTP 200** with **no\n`data` key at all**, only an `errors` array:\n\n```json\n{ \"errors\": [{ \"message\": \"...\", \"extensions\": { \"path\": \"$\", \"code\": \"validation-failed\" } }] }\n```\n\nThis is captured behaviour, not a GraphQL-spec default — SP0's\n`error-unauthorized.json`/`error-permission.json`/etc. all show the real\nbackend doing exactly this (see `fixtures/README.md`). A consumer that\nchecks `response.status` to detect a GraphQL error will never see one from\nthis adapter, exactly like the real backend. The codes this adapter can\nemit: `validation-failed` (a malformed or unimplemented argument/operator —\nthe catch-all), `data-exception` (a `where` target that cannot possibly\nmatch its column's type, e.g. a non-numeric `_eq` against a bigint `id`),\n`access-denied` (an unroutable root field name), `invalid-jwt` (a presented\n`Authorization` header that does not resolve to a session), and\n`constraint-violation` (an `on_conflict` target that exists but is invisible\nto the caller, or a delete against a `deleteForbiddenTables` table — see\nbelow).\n\n### `_in` does not preserve the requested order\n\n`where: {id: {_in: [...]}}` returns matching rows in the table's own default\norder (ascending `id`), **not** the order the array was given in — proven\nagainst the live backend, not assumed: `batch-in.json` requested ids\n`[3154, 3153, 3245, 3312, 3314]` and got back\n`[3153, 3154, 3245, 3312, 3314]`. A consumer that needs the caller's order\nmust re-sort client-side; this mock reproduces the live re-ordering rather\nthan the more convenient \"preserve request order\" behaviour, because that\nconvenience isn't what the real backend does.\n\n### `deleteForbiddenTables` — a delete that can never succeed\n\n`HasuraAdapterOptions.deleteForbiddenTables` (a `Set` of Hasura table names —\nthe same keyspace `tables` uses, checked at construction time to name only a\nrouted table) declares a table where a delete must always fail — added for\n`document_document`'s real `AFTER DELETE` trigger defect (see\n`error-delete-history-trigger` in `fixtures/README.md`). A\n`delete_<table>_by_pk`/`delete_<table>` against a declared table answers\n`constraint-violation`, checked *after* every argument is validated but\n*before* the store is ever touched: a malformed `where`/`id` on a\ndelete-forbidden table still reports `validation-failed`, not\n`constraint-violation`, because document validation and runtime execution\nare different failure layers on a real GraphQL server, and this refusal\nbelongs to the second one. A table absent from this set is completely\nunaffected — nothing here polices it automatically, because this mock has no\ntrigger/constraint system of its own to discover the defect on its own; it\nmust be declared, the same way `conflictKeys` must be.\n\n### Multi-root mutations are not atomic\n\nA single mutation document can carry more than one root field (`mutation {\na: insert_x_one(...) { id } b: delete_y(where: ...) { affected_rows } }`).\n`adapter.ts` executes each root in a plain loop and writes its result under\nits own response key; there is no transaction wrapping the whole document.\nIf a later root throws, the roots executed before it have **already been\nwritten to the store** — nothing rolls them back — while the HTTP response\nstill comes back with no `data` key at all (see \"Error shape\" above), the\nsame as any other failure. A client that correctly handles the error can\nstill be left with a partially-applied write: from the response alone it\nsees only an error, but part of the document's effect already landed in the\nstore. Real Hasura runs every mutation root of one document inside a single\nPostgres transaction, so a failure on any root rolls back all of them; this\nmock does not reproduce that. Deliberately left as a documented divergence\nrather than fixed with snapshot/rollback machinery — send mutation roots\nthat must succeed or fail together as separate requests if that matters to\nyour demo.\n\n### Hasura adapter — fidelity sweep findings (Task 6)\n\n`hasuraAdapter` (the `./hasura` subpath) now exists — built across Tasks 1-5\nof the `2026-08-11-sp1b-hasura-adapter` sub-project — and every fixture in\n`fixtures/hasura/` has been replayed through it and shape-compared against\nthe real capture (`tests/hasura-fidelity.test.ts`; a full capability writeup\nbelongs to that sub-project's Task 7, not repeated here). Two things this\nsweep learned about the LIVE BACKEND that were not known before, plus how the\nadapter now models them:\n\n- **Every table's `id` column serializes as a bare JSON *number*** (e.g.\n  `\"id\": 3314`), never a quoted string — true across all five tables this\n  sub-project's fixtures cover (`document_document`, `reference_locations`,\n  `company_company`, `reference_freeform_templates`,\n  `document_document_data`). `hasuraAdapter` closes the gap at the HTTP\n  boundary: declare the table's `id` column `type: 'bigint'` in\n  `HasuraAdapterOptions.schemas` (the id-semantics sub-project's Task 4\n  removed the original, separately-opt-in `bigintIdTables` option and folded\n  its behaviour into this one declaration — there is no other opt-in\n  anymore), and both the response shape (`id` comes back numeric) and\n  `where: {id: {_eq: ...}}`/`{_in: ...}` matching follow — the STORE now\n  holds a `bigint`-declared table's id in its natural `number` type (the\n  id-semantics sub-project's Task 5), so a numeric target compares equal via\n  a bare `===`; there is no string-to-number coercion left on this path at\n  all. A target that cannot represent an\n  integer at all (`_eq: \"not-a-number\"`) now throws and answers\n  `data-exception`, matching the real backend, instead of silently matching\n  zero rows. A FRESH INSERT into a `bigint`-id table also gets a\n  purely-numeric id (`identity.ts`'s `generateId`/`nextBigintId`, one past\n  the table's current highest numeric id) instead of `store.ts`'s own\n  generic `<table>_<n>` format — without this, an insert-then-filter round\n  trip (the canonical create-a-row-then-look-it-up flow) would throw\n  `data-exception` trying to filter by the very id the mock had just handed\n  back, and a plain list of the table would come back with a MIX of numeric\n  and string ids in the same array. Fix round 1 (reviewer-found Critical 1);\n  see the task-6 report for the full repro.\n\n  **Mis-seeded risk, split into two very different outcomes (read before\n  declaring a table `bigint`):** what happens to a hand-seeded (or\n  `Store.insert`-bypassed) row whose `id` is not a genuine `number` on a\n  `bigint`-declared table depends entirely on which shape the bad id has.\n\n  - A **digit-only string** id (`'5'`, matching `/^\\d+$/`) is caught loudly,\n    but only on paths that PROJECT the row: `select.ts`'s `projectRow`\n    throws a plain `Error` the moment that row's `id` field is read back (any\n    `list`/`_by_pk`/relation response that includes it). A path that only\n    COMPARES against a target — `where: {id: {_eq: ...}}` in a query, or a\n    mutation's `where` — never calls `projectRow` at all, so that same row\n    is simply invisible there instead: the stored `'5'` is never `===` the\n    coerced numeric target `5`, so it silently fails to match rather than\n    throwing. A mis-seeded digit-string id is therefore loud on read, quiet\n    (empty results, not an error) on every filter.\n  - A **non-digit-only** id (`'abc'`, a UUID, anything with a non-digit\n    character) hits neither check: `PURE_DIGITS` never matches it, so\n    `projectRow` serializes it as a string exactly as stored, right next to\n    every genuinely-numeric row in the same table serializing as a number —\n    reproducing the exact heterogeneous-array shape this option exists to\n    prevent (`{\"items\":[{\"id\":1},{\"id\":\"abc\"}]}`). Nothing in this adapter\n    validates that a `bigint`-declared table's seed is actually all-numeric,\n    and it will not throw or warn if it isn't — a genuine, open sharp edge,\n    not silently swept under the rug: if you declare a table bigint-id, make\n    sure every row your seed (and every write path) ever gives it truly has\n    a numeric-looking `id`.\n\n  **The `Number.MAX_SAFE_INTEGER` (2**53 - 1) ceiling, and what is and isn't\n  handled at it:** a JS `number` — and therefore a bare JSON number,\n  `JSON.stringify`'s only way to emit one — cannot represent an integer past\n  this magnitude exactly. `identity.ts`'s `nextBigintId` (relocated out of\n  `mutation.ts` by the id-semantics sub-project; same algorithm) uses\n  `BigInt` internally and REFUSES (throws) rather than hand out a fresh id\n  past this boundary — the write side of the ceiling. This throws a plain,\n  loud `Error` rather than silently rounding — which, before fix round 3,\n  is exactly what used to make two DIFFERENT rows serialize with the\n  IDENTICAL projected `id`.\n\n  On the READ side, `select.ts` no longer has a magnitude-specific backstop\n  of its own: as of the id-semantics sub-project, ANY digit-only string `id`\n  on a `bigint`-declared column throws unconditionally (see \"Mis-seeded\n  risk\" above), whether or not that string happens to be past\n  `Number.MAX_SAFE_INTEGER` — the narrower, magnitude-only check this\n  paragraph used to describe on the read side has been subsumed by that\n  broader, simpler rule. What is NOT, and cannot be, handled: a client-sent\n  `id` value that arrives as a bare JSON number (or a bare GraphQL `Int`\n  literal in the query text, parsed by `document.ts`) past this same\n  magnitude has already lost precision before this adapter's code ever\n  runs — `JSON.parse`/the GraphQL parser's own integer handling did that,\n  not this package, and there is no way to recover the \"true\" value after\n  the fact. **Sending it as a GraphQL STRING-typed variable does NOT avoid\n  this anymore**, unlike before the id-semantics sub-project:\n  `identity.ts`'s `coerceBigintTarget` now converts a numeric-string target\n  to a `number` too (the store holds a `bigint` id in its natural `number`\n  type end to end, per \"The three id regimes\" above), so a string target\n  past `Number.MAX_SAFE_INTEGER` is refused with a loud `Error`, exactly\n  like a bare-number target that far out — not silently passed through\n  unconverted the way the pre-id-semantics `coerceIdTarget` used to.\n- **A credential that was OFFERED but fails to verify is never silently\n  downgraded to anonymous.** `error-unauthorized.json`'s deliberately\n  malformed bearer token proves this on the LIVE BACKEND: the real gateway\n  answers `invalid-jwt`, not a 200. `hasuraAdapter` makes the same\n  distinction — an `Authorization` header present but unresolvable by\n  `SessionRegistry` answers `invalid-jwt`, only when the adapter was\n  actually given a `sessions` registry to begin with.\n\n  **Not corroborated by any capture, and stated separately on purpose:**\n  what happens with NO `Authorization` header at all is a MOCK behavioural\n  choice (`hasuraAdapter`'s pre-existing, Task-5 \"no session → 200, empty\n  lists, no throw\" design), not a sweep finding about the real backend — no\n  fixture was ever captured without an `Authorization` header, so the real\n  gateway's behaviour for a fully anonymous request (falls back to an\n  unauthenticated role? answers a different error? something else?) is\n  simply unknown. Do not read the first paragraph as implying the second is\n  equally verified.\n\nOne narrower finding remains an accepted, permanent boundary rather than\nfixed, because closing it for real would require a GraphQL type system for\nINPUT OBJECTS this mock deliberately does not have:\n\n- `error-invalid-mutation.json` (a GraphQL variable declared with a bogus\n  type name) — schema-level variable-type validation needs a schema for\n  input-object types (`document_document_set_input` and friends), not just\n  scalar column types; the mutation simply executes with whatever value the\n  variable actually holds.\n\n**`error-unknown-field.json` is CLOSED, not an accepted boundary anymore.**\nThis section originally recorded it alongside `error-invalid-mutation.json`\nabove, with a follow-up idea attached: a DECLARED (not row-derived) column\nlist per table would let `select.ts` distinguish \"not a real column\" from \"a\nreal, nullable column with no value on this row.\" A LATER sub-project\n(id-semantics, Task 7) built exactly that: `projectRow` now consults\n`HasuraAdapterOptions.schemas` via `columnOf` for every non-relation field a\nselection names, and a field that isn't one of the table's declared columns\nthrows (`validation-failed`) instead of quietly returning `null`. A declared\ncolumn that is genuinely absent from a given row still reads as `null`,\nunchanged — only a field that isn't declared AT ALL is now refused.\n`mutation.ts`'s `knownColumnsOf` is the WRITE side's own version of the same\nDECLARED authority, and has been schema-first since Task 7 — not row-derived,\nand not something Task 7 skipped. What Task 7 actually made schema-first on\nthe write side was `insert`'s `object`; `_set` and `on_conflict.update_columns`\nwere the two write paths still validating against the target row's own\nobserved keys at that point. The final id-semantics fix-wave's Critical 2\nfix closed that gap too: `buildSetPatch` (`_set`) and `applyOnConflictUpdate`\n(`on_conflict.update_columns`) now call the SAME `knownColumnsOf` `object`\nalready used, so all three write paths share one DECLARED authority — see\n\"Schema authority on writes now covers `insert`, `_set`, and\n`on_conflict.update_columns` alike\" above.\n\n**Fixture accounting, current:** 37 fixtures total (8 added by SP3a task 1's\n`where-*.json` combinator captures — `_and`/`_or`/`_not`, nesting, a\ncombinator beside a plain field, both empty-array forms), 0 skipped, 1\ndocumented divergence (`error-invalid-mutation`), **36 fully verified**. The\nskipped\nbucket, once 2 (`ws-subscription`/`ws-subscription-delta`), is now genuinely\nempty: SP1c's `hasuraWsAdapter` (see \"WebSocket subscriptions\" below)\nreplays both captured WS fixtures over a real socket\n(`tests/hasura-fidelity.test.ts`'s `replayWsFixture`), asserting the exact\nframe-TYPE sequence and, for every `next` frame, its payload SHAPE — the\nsame positive standing every non-divergent HTTP fixture already has, not a\nweaker \"it didn't throw\" check. `tests/hasura-fidelity.test.ts`'s own\ncoverage-accounting test pins these four numbers, so a silent regression in\neither direction — a fixture quietly falling out of \"fully verified,\" or a\nnew fixture landing in no bucket at all — fails the suite rather than going\nunnoticed.\n\nA third finding, `error-delete-history-trigger.json`, was originally\nrecorded alongside the two above under the same \"requires a GraphQL schema\"\nrationale — but that rationale never actually applied to it, and it is now\n**closed**, not an accepted boundary: `delete_document_document_by_pk`/\n`delete_document_document(where:)` cannot succeed on the real backend (an\n`AFTER DELETE` trigger violates a NOT NULL constraint), and this mock has no\ntrigger/schema system of its own to discover that on its own — but it\ndoesn't need one, because the divergence is closed by an explicit\nDECLARATION instead: `HasuraAdapterOptions.deleteForbiddenTables` (documented\nin full under \"`deleteForbiddenTables` — a delete that can never succeed\"\nabove), which is exactly the same \"declare it, don't guess it\" shape\n`conflictKeys`/`schemas` already use elsewhere in this adapter. A\ndelete against a table declared there now answers `constraint-violation`,\nmatching the real backend, instead of silently succeeding.\n\n### WebSocket subscriptions — `hasuraWsAdapter` reproduces captured reality\n\n`hasuraWsAdapter` (the `./hasura` subpath, alongside `hasuraAdapter`) serves\nHasura-shaped GraphQL subscriptions over `graphql-ws`\n(`ws.link('*<basePath>/v1/graphql')`), taking `HasuraWsAdapterOptions` — a\n`Pick` of `basePath`/`tables`/`schemas`/`relations`/`sessions`/`scope` out of\nthe same `HasuraAdapterOptions` `hasuraAdapter` takes, so one options object\ncan build both adapters and they will agree about what a table/column/\nrelation name means (same construction-time checks, same translation\nfunctions). It runs every subscription through the exact `runQueryRoot`\n`hasuraAdapter`'s HTTP query path already uses — there is no second query\nengine for subscriptions to drift from the query one.\n\n**This surface reproduces captured reality, not an invented protocol.** Two\n`graphql-ws` frame sequences (`ws-subscription`/`ws-subscription-delta` in\n`fixtures/hasura/`) were captured against a production Hasura backend\n(2026-08-11) and are replayed byte-for-sequence against\nthis adapter over a real socket in `tests/hasura-fidelity.test.ts` — see\n\"Fixture accounting, current\" above. Two facts from those captures are worth\nstating as facts, not inferences:\n\n- **`ping` arrives BEFORE `connection_ack`.** The captured order is\n  `connection_init` → `ping` → `pong` → `connection_ack` → `ping` → `pong` →\n  `subscribe` → `next` → `complete`. `ws-protocol.ts`'s handshake state\n  machine reproduces this structurally, not by coincidence: `connection_init`\n  always yields a `ping` immediately, and `connection_ack` is only ever\n  produced later, as part of handling the client's first `pong` — so an ack\n  can never be emitted before the first ping is.\n- **The second `next` frame is a FULL RE-PUSH with new values, not a\n  delta.** `ws-subscription-delta.json`'s filename suggests a diff; its\n  actual second frame carries the complete `document_document_aggregate`\n  shape again, just with `count` (and `max.updated_at`) changed — that is how\n  a live Hasura subscription actually behaves (a polling live-query under the\n  hood, not a diff stream), and `subscription.ts` reproduces that faithfully.\n  `ws-protocol.ts`'s own `NextFrame` doc comment already carries this\n  correction; the filename itself was never fixed, on purpose, so it stays\n  discoverable as a documented gotcha rather than quietly renamed.\n\nOne invented shape","readmeFilename":"README.md"}