@burojs/ops-client
v0.2.0
Published
Buro: thin client for a neutral operations RPC surface (POST <baseUrl>/<operation>, a {result}/{error} envelope) — not a DataProvider
Downloads
98
Readme
@burojs/ops-client
A thin client for a neutral RPC surface — POST <baseUrl>/<operation>
over a JSON body, answering { result } on success or { error: { kind,
code, message } } on failure. For buro.
This is not a DataProvider. It has no notion of a resource, a list, or
CRUD — it calls one named operation and hands back (or throws) whatever that
operation returns. Wiring a specific operation behind a framework
defineQuery/defineMutation (@burojs/core's queries.custom /
mutations.custom seam) is a separate, deliberately later decision, made by
whoever knows what that operation's own domain error codes mean.
Install
pnpm add @burojs/ops-clientZero runtime dependencies — this package is plain fetch, nothing else.
Usage
import { createOpsClient, OpsCallError } from '@burojs/ops-client';
const ops = createOpsClient({
baseUrl: 'https://example.com/api/marketplace/ops',
getToken: () => authProvider.getToken(),
});
try {
const summary = await ops.call('exportOrders', { status: 'paid' });
} catch (error) {
if (error instanceof OpsCallError) {
// error.kind is 'transport' | 'domain' — check this FIRST.
// error.code disambiguates within it; error.status is the HTTP status.
}
}createOpsClient({ baseUrl, getToken, fetchImpl? })
baseUrl— origin + mount path, e.g.https://host/api/marketplace/ops. A trailing slash is tolerated.getToken: () => string | null | Promise<string | null>— called on everycall(). A resolved non-nullvalue is sent asAuthorization: Bearer <token>;nullomits the header entirely.fetchImpl?: typeof fetch— defaults to the globalfetch.
Returns { call }.
call(operation, args?, options?)
Posts args (defaulting to {} — the wire contract requires a well-formed
JSON body even for an argument-less operation) as the JSON body to
<baseUrl>/<operation>, and resolves with the operation's own result value
on success. options.signal, if given, is forwarded to fetch.
Rejects with OpsCallError on any failure:
class OpsCallError extends Error {
readonly kind: 'transport' | 'domain';
readonly code: string;
readonly status: number; // 0 when no response was ever received
readonly details?: unknown;
}kind is the field to check first — mirrors the wire contract's own framing
of it as the one part of the envelope fully closed to 'transport' |
'domain'. code disambiguates within it:
- For
kind: 'transport',codeis one of the RPC surface's own five transport codes (invalid-request/unknown-operation/invalid-arguments/unauthorized/internal-error, exported as theOpsTransportErrorCodetype for reference) — or one of this client's OWN two codes for a failure that never produced a parseable response at all:network-error(the request failed, or the response was not this contract's envelope) orrequest-aborted(the call'sAbortSignalfired).opsAdapternever sends these last two; a caller that only recognises the five wire codes still catches them correctly by checkingkind === 'transport'first. - For
kind: 'domain',codeis whatever free-form string the operation's own author chose (OperationError'scodeon the server side) — this client does not know or enumerate it.
The contract, and why this package doesn't import an implementation of it
The one implementation of this contract in this repository is
@burojs/mock-kit's opsAdapter (packages/mock-kit/src/ops/adapter.ts).
This package does not depend on it, at runtime or in its type exports: the
whole reason the demo program chose a neutral RPC here (program spec
docs/superpowers/specs/2026-08-11-marketplace-ops-demo-design.md §11 —
"адоптеру понятнее вендор-независимый контракт", "an adopter finds a
vendor-independent contract easier to understand") is that a client is
written against the WIRE SHAPE, not against whichever backend happens to
implement it today. A real backend that speaks the same envelope needs no
different client than the mock does.
This package's own test suite (tests/client.test.ts) proves the two stay in
step the honest way: it mounts a real opsAdapter (a devDependency, used only
in tests) and drives it over real HTTP through this client — not against a
hand-written fixture of what the maintainer believes the server returns.
What this package deliberately does not decide
Translating an OpsCallError into @burojs/core's DataError taxonomy is
left to whoever wires a specific operation behind queries.custom /
mutations.custom. DataError.kind is a closed set (configuration |
capability | transport | auth | validation | protocol | cancelled |
conflict) with no 'domain' member, and only that caller knows what a given
operation's own domain code MEANS — is a "blocked product" sync failure a
conflict? a validation? This package has no way to guess that generically,
and a wrong generic guess would be worse than leaving the seam open.
License
MIT
