@october1/client
v0.1.0
Published
A small client for october: GraphQL reads and writes that retry safely, live queries, sign-in, and React hooks.
Maintainers
Readme
@october1/client
A small client for one october tenant: GraphQL reads and writes, live queries, and sign-in by email code. No runtime dependencies; about 7 KB gzipped.
import { init } from "@october1/client";
const db = init({ url: "https://october.example", tenant: "shop" });
// same origin as the page: init({ tenant: "shop" })
// sign in
const { demoCode } = await db.auth.sendMagicCode({ email });
await db.auth.signInWithMagicCode({ email, code });
// read
const { products } = await db.query(`{ products(first: 20) { items { id name } } }`);
// write: every call carries an Idempotency-Key, so retries never write twice
await db.mutate(`mutation ($input: ProductCreateInput!) { createProduct(input: $input) { id } }`,
{ input: { name: "Lamp" } });
// live: the first result, then every change
const stop = db.subscribe(`{ products(first: 20) { items { id name } } }`, {}, ({ data, mine }) => render(data));React
import { init, id } from "@october1/client/react";
const db = init({ tenant: "chat" });
const MESSAGES = `{ messages(first: 50) { items { id text createdAt } } }`;
const SEND = `mutation ($input: MessageCreateInput!) { createMessage(input: $input) { id } }`;
function Chat() {
const { isLoading, error, data } = db.useQuery(MESSAGES); // live: re-renders on every change
const { user } = db.useAuth();
const send = (text) => db.mutate(SEND, { input: { id: id(), text } });
if (isLoading) return <p>Loading…</p>;
if (error) return <p>{error.message}</p>;
return <UI messages={data.messages.items} canSend={!!user} onSend={send} />;
}db.useQuery(doc, variables, { skip })answers{isLoading, data, error, mine, basisT}. Anulldoc runs nothing. Components asking for the same query and variables share one live stream (the server allows a user 8), and the stream closes when the last of them unmounts.db.useAuth()answers{isLoading, user, token}and follows sign-in, sign-out and other tabs.id()makes a new entity id. Creates takeinput.id, so a component can name what it creates before the server answers.
React is an optional peer dependency. The core (@october1/client)
needs nothing.
Writes are retried safely
mutate sends an Idempotency-Key header (a random one per call, or
{ idempotencyKey }). If the request times out, the network fails, or
the server answers UNAVAILABLE, CONFLICT or RATE_LIMITED, the
client sends the same request again with the same key. If the write
committed the first time, the server answers with that commit and does
not write again. Reads are retried the same way, without a key.
Retries wait for the server's retryAfterMs, else a short backoff. They
give up after attempts tries (4), or when the server asks for a wait
longer than maxRetryWaitMs (10 s).
Errors
Every failure is an OctoberError:
code: the GraphQLextensions.code(VALIDATION_FAILED,FORBIDDEN,NOT_FOUND,UNAUTHENTICATED,CONFLICT,UNAVAILABLE,RATE_LIMITED, ...). For a REST refusal, its type in upper snake case (INVALID_CODE,RATE_LIMITED,UNKNOWN_TENANT). ElseNETWORKorTIMEOUT.fields: forVALIDATION_FAILED,{field: [message]}.retryAfterMs,status,details(the rest of the error).data: what did commit. Each mutation field in a request is its own transaction.retryable.
db.request(doc, vars) returns the whole response (data, errors,
extensions.cost) instead of throwing on GraphQL errors.
Live queries
db.subscribe(doc, variables, callback, { onStatus, onError }) calls
callback with {data, errors, basisT, mine, cost}. mine is true when
every write in the push was the signed-in user's own. The client:
- gets a live ticket when signed in;
- reconnects with backoff, and when the stream goes quiet (no heartbeat
for
liveIdleMs, 45 s); - reopens every stream as the new caller when the sign-in changes.
A query the server refuses (a parse error, a mutation, over the cost
limits) calls onError and the subscription stops. It runs over
fetch, so it works in Node too.
Sign-in
The token is kept in localStorage under october:<tenant>:token
(tokenKey, storage; storage: null keeps it in memory). Another
tab's sign-in or sign-out is picked up.
db.auth.token,db.auth.onChange(fn),db.auth.setToken(t).db.auth.me()answers{id, email}or null. A token the server no longer knows is forgotten.db.auth.signOut()revokes the token on the server. Its live streams end.db.config()answers{demo}.
Types (gql.tada)
Documents are strings or typed documents: gql.tada's, or any
TypedDocumentNode. Results and variables are then typed from the
tenant's SDL:
curl -fsS https://october.example/t/shop/schema.graphql -o schema.graphqlimport { initGraphQLTada } from "gql.tada";
import type { introspection } from "./graphql-env.d.ts";
import { init, type Scalars } from "@october1/client";
const graphql = initGraphQLTada<{ introspection: introspection; scalars: Scalars }>();
const PRODUCT = graphql(`query ($id: UUID!) { product(id: $id) { id name } }`);
const { product } = await db.query(PRODUCT, { id }); // typedScalars maps october's scalars: UUID and Instant are strings,
Long is a number or a decimal string, JSON is unknown.
Development
npm install
npm test # unit tests, plus test/server.test.ts against OCTOBER_URL
# (default localhost:8900, the dev JVM; skipped when it isn't up)
npm run typecheck # includes test/types.ts, typed from test/forum.graphql
npm run build # dist/, and resources/public/october-client.js for october's own pages
npm run forum-types # refresh test/forum.graphql and its types from the dev serveroctober's pages load /october-client.js, a copy that npm run build
writes. The copy's header names the hash of the source it was built
from, and october.client-copy-test fails when the copy is stale.
