JavaScript SDK
npm i @groveback/sdkOne factory returns everything. The SDK talks only to the public REST and WebSocket API.
createClient
Section titled “createClient”import { createClient } from '@groveback/sdk';
const client = createClient({ baseUrl: 'https://api.example.com', onTokensChanged: (tokens) => tokens ? localStorage.setItem('gb', JSON.stringify(tokens)) : localStorage.removeItem('gb'),});interface ClientOptions { baseUrl: string; // '/api/v1' is appended; trailing slash stripped projectId?: string; // sent as the x-groveback-project header apiKey?: string; // server-to-server gb_sk_… fetch?: (req: Request) => Promise<Response>; webSocket?: (url: string) => WebSocketLike; onTokensChanged?: (tokens: Tokens | null) => void;}Supporting types:
interface Tokens { accessToken: string; refreshToken: string; }class ApiError extends Error { status: number; code: string; }type Doc = Record<string, unknown>;interface FindOptions { filter?: Record<string, unknown>; limit?: number; skip?: number; }type ChangeType = 'insert' | 'update' | 'replace' | 'delete';interface ChangeEvent { type: ChangeType; collection: string; documentId: string; document?: Doc; }type Unsubscribe = () => void;Token handling. The access/refresh pair lives in a closure. On a 401 with a refresh token
available, the request transparently refreshes once and retries; a failed refresh clears
the tokens and rethrows the original error. Non-2xx responses become a thrown ApiError
parsed from the { error: { code, message } } envelope.
With apiKey, every request authenticates with it, auth.* is unused, and subscribe()
throws UNSUPPORTED — realtime needs a user, not a service key.
register(email: string, password: string, name?: string): Promise<Doc>login(email: string, password: string): Promise<Doc> // may resolve { mfaRequired: true }verifyMfa(code: string): Promise<Doc> // TOTP or a backup codeenrollTotp(): Promise<{ secret: string; otpauthUrl: string }>activateTotp(code: string): Promise<{ backupCodes: string[] }>disableTotp(code: string): Promise<void>
oauthStartUrl(provider: string, redirect: string): string // navigate the browser herehostedLoginUrl(redirect: string): stringcompleteOAuth(fragment: string): Promise<Doc> // reads #mb_refresh / #mb_mfa / #mb_error
refresh(): Promise<void>logout(): Promise<void>sessions(): Promise<Doc[]>revokeSession(sessionId: string): Promise<void>
requestEmailVerification(email: string): Promise<void> // always resolvesverifyEmail(token: string): Promise<void>requestPasswordReset(email: string): Promise<void> // always resolvesresetPassword(token: string, password: string): Promise<void>
getTokens(): Tokens | nullsetTokens(next: Tokens | null): voidverifyMfa throws ApiError(400, 'NO_MFA_PENDING') when there is no pending challenge.
completeOAuth throws OAUTH_FAILED (401) on #mb_error and OAUTH_INCOMPLETE (400) when
the fragment carries no result.
The two “always resolves” methods are anti-enumeration: they succeed whether or not the account exists.
collection
Section titled “collection”const posts = client.collection('posts');find(opts?: FindOptions): Promise<Doc[]> // GET /:name?filter&limit&skipget(id: string): Promise<Doc> // GET /:name/:idcreate(doc: Doc): Promise<Doc> // POST /:nameupdate(id: string, patch: Doc): Promise<void> // PATCH — $set semantics, $-ops pass throughreplace(id: string, doc: Doc): Promise<void> // PUT — id and ownership preserved server-sidedelete(id: string): Promise<void>search(opts: { text?: string; vector?: number[]; limit?: number; filter?: Doc }): Promise<Array<Doc & { _score: number }>>subscribe(event: ChangeType | '*', cb: (event: ChangeEvent) => void): Unsubscribefind’s filter is JSON-stringified into the query string and omitted when empty. search
requires a vector config on the collection; results are ranked
closest-first and pass the same read policy as find.
storage
Section titled “storage”storage.buckets.create(name: string, access?: 'private' | 'public'): Promise<Doc>storage.buckets.list(): Promise<Doc[]>storage.buckets.setAccess(name: string, access: 'private' | 'public'): Promise<void>storage.buckets.delete(name: string): Promise<void>
storage.upload(bucket: string, path: string, data: Blob | ArrayBuffer | Uint8Array | string, contentType?: string): Promise<Doc>storage.download(bucket: string, path: string): Promise<{ data: ArrayBuffer; contentType: string }>storage.delete(bucket: string, path: string): Promise<void>storage.list(bucket: string, opts?: { prefix?: string; limit?: number; skip?: number }): Promise<Doc[]>storage.createFolder(bucket: string, folder: string): Promise<Doc>storage.signUrl(bucket: string, path: string, expiresInSec: number): Promise<{ url: string; expiresAt: string }>buckets.* hits admin routes, so it needs an Admin/Owner caller or an admin-scoped key. File
bodies go through a binary path with the same auth and 401-refresh logic. contentType
defaults to the Blob’s type, or application/octet-stream. Path segments are individually URL
encoded, so / in a path stays a separator.
graphql
Section titled “graphql”graphql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T>Throws ApiError(400, 'GRAPHQL') when the response carries errors and no data; otherwise
returns data.
Realtime
Section titled “Realtime”One lazy WebSocket per client, opened on the first subscribe() and multiplexed across
subscriptions.
const stop = client.collection('posts').subscribe('*', (event) => { console.log(event.type, event.documentId);});stop();On open it sends auth, then flushes pending subscriptions. A server TOKEN_EXPIRED error
triggers a refresh and re-auth transparently.
Two constraints in this version:
- No automatic reconnect. A closed socket nulls itself and marks entries unsent; the next
subscribe()reopens it. - Unsubscribing the last subscription closes the socket.
Errors
Section titled “Errors”import { ApiError } from '@groveback/sdk';
try { await posts.create({ });} catch (e) { if (e instanceof ApiError && e.code === 'VALIDATION') { console.error(e.message); }}See the error reference for every code.
Typed instead
Section titled “Typed instead”For types generated from your live schema, use grove gen and import createGrove —
details in the CLI reference. The generated file rides
on this SDK, so everything above still applies.