Skip to content

JavaScript SDK

Terminal window
npm i @groveback/sdk

One factory returns everything. The SDK talks only to the public REST and WebSocket API.

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 code
enrollTotp(): 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 here
hostedLoginUrl(redirect: string): string
completeOAuth(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 resolves
verifyEmail(token: string): Promise<void>
requestPasswordReset(email: string): Promise<void> // always resolves
resetPassword(token: string, password: string): Promise<void>
getTokens(): Tokens | null
setTokens(next: Tokens | null): void

verifyMfa 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.

const posts = client.collection('posts');
find(opts?: FindOptions): Promise<Doc[]> // GET /:name?filter&limit&skip
get(id: string): Promise<Doc> // GET /:name/:id
create(doc: Doc): Promise<Doc> // POST /:name
update(id: string, patch: Doc): Promise<void> // PATCH — $set semantics, $-ops pass through
replace(id: string, doc: Doc): Promise<void> // PUT — id and ownership preserved server-side
delete(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): Unsubscribe

find’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.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<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.

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.
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.

For types generated from your live schema, use grove gen and import createGrovedetails in the CLI reference. The generated file rides on this SDK, so everything above still applies.