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 createGrove — details in the CLI reference. The generated file rides on this SDK, so everything above still applies.