npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@motherbase/client

v1.3.1

Published

Typed client SDK for Motherbase, the open source self-hosted backend platform.

Readme

@motherbase/client

A supabase-js-shaped client SDK for the Motherbase self-hosted backend. It keeps the same entry points (createClient, .from(), .auth, .storage, .channel(), .functions) and the same { data, error } result convention, so supabase-js code reads and reviews the same way here.

Scope, honestly. For the data layer, this is a package swap: change the import and the client factory, keep your queries. Every supabase-js data call in the contract suite is either supported or supported with a stated difference, including relational embedding (select('*, author(*)')), .rpc(), native upsert, count, csv(), the full filter set and PostgREST error codes. Auth and realtime are close, with named gaps (no PKCE code exchange, no mfa namespace on the client, no private channels).

Storage is path-addressed too: upload('avatars/123/pic.png') then download('avatars/123/pic.png') round trips, list(prefix) works, and keys are traversal-checked. Still missing there: move, copy, batch signed URLs and image transforms, and authorization is ownership plus bucket tokens rather than RLS policies on the objects table. See docs/migrating-from-supabase.md.

Do not plan a migration from the paragraph above. Plan it from COMPATIBILITY.md, which is generated from contract tests and is exhaustive, per method, with the exact difference for every partial row.

Typed, isomorphic (browser, Next.js server & client, Expo / React Native), built on global fetch with zero runtime dependencies (socket.io-client is an optional peer dependency, used only for realtime).

Install

npm i @motherbase/client
# optional, only if you use realtime:
npm i socket.io-client

Quickstart

import { createClient, localStorageStorage } from '@motherbase/client';

const client = createClient(
  'http://localhost:5001',
  'your-anon-key', // optional; sent as an `apikey` header
  { storage: localStorageStorage() } // omit for in-memory (SSR-safe) sessions
);

await client.hydrate(); // restore a persisted session (optional)

// Auth
await client.auth.signUp({ email: '[email protected]', password: 'secret123' });
const { data, error } = await client.auth.signInWithPassword({
  email: '[email protected]',
  password: 'secret123',
});

// Query builder (awaitable, PostgREST-style)
const { data: products } = await client
  .from('products')
  .select('id,name,price')
  .lt('price', 50)
  .order('name', { ascending: false })
  .limit(20);

// Storage. `uploaded.path` is the key you passed, so later download/remove/
// signed-URL calls take the same string.
const { data: uploaded } = await client.storage.from('avatars').upload(`${userId}/me.png`, myBlob, {
  visibility: 'public',
});

// Realtime: postgres_changes, broadcast, presence over one channel (Supabase-shaped)
const room = client
  .channel('room-1', { config: { broadcast: { self: true }, presence: { key: userId } } })
  .on('postgres_changes', { event: '*', schema: 'public', table: 'messages', filter: 'room_id=eq.42' }, p => {
    console.log(p.eventType, p.new, p.old);
  })
  .on('broadcast', { event: 'cursor' }, ({ payload }) => console.log(payload))
  .on('presence', { event: 'sync' }, () => console.log(room.presenceState()))
  .subscribe();

await room.send({ type: 'broadcast', event: 'cursor', payload: { x: 1, y: 2 } });
await room.track({ online_at: Date.now() });

// Edge functions
const { data: result } = await client.functions.invoke('hello', { body: { name: 'Ada' } });

Every data/auth/storage call resolves to { data, error } and never throws for expected API errors. error is null on success, otherwise { message, status?, code? }.

The SDK attaches the access token automatically and revalidates it on a 401 (single-flight: concurrent requests share one revalidation). Sessions are Better Auth bearer sessions, so there is no separate refresh token to rotate: when the token is no longer valid the SDK clears the session and emits SIGNED_OUT.

API surface (cheatsheet)

// Data. Filters: eq neq gt gte lt lte like ilike likeAllOf likeAnyOf ilikeAllOf
// ilikeAnyOf in is not or filter match textSearch contains containedBy overlaps
// rangeGt rangeGte rangeLt rangeLte rangeAdjacent
client.from(table).select(cols).eq(col, v).in(col, [..]).order(col, { ascending, nullsFirst, referencedTable })
client.from(table).select(cols).limit(n, { referencedTable }).range(from, to).abortSignal(signal)
client.from(table).select(cols).or('a.eq.1,and(b.gt.2,c.is.null)')   // nests, as PostgREST does
client.from(table).select(cols).not(col, 'eq', v).filter(col, 'isdistinct', v)
client.from(table).select(cols).textSearch(col, q, { type: 'websearch', config: 'english' })
client.from(table).select(cols).single() | .maybeSingle() | .returns<T>() | .csv() | .explain()
client.from(table).insert(row) | .insert([rowA, rowB], { defaultToNull: false })
client.from(table).upsert(rows, { onConflict, ignoreDuplicates })
client.from(table).update(values).eq(..)         // a filter is required
client.from(table).delete().eq(..)               // a filter is required
// Every call resolves { data, error, count }. `count` is null unless you ask:
// .select('*', { count: 'exact', head: true }).
// Values are quoted for you: .in('name', ['a,b']) is one value, not two.
// Writes always return their rows: no .select() needed, and no way to opt out.
// An unbounded select is capped at 100 rows server side: paginate with .range().
// explain() needs an admin caller; a normal caller gets 403.

// Relational embedding. Compiles to a correlated LATERAL subquery inside the
// same RLS transaction, so embedded rows are policy-filtered too.
client.from('posts').select('id, author(name), comments(id)')     // object / array by FK shape
client.from('posts').select('id, author!inner(name)')             // inner join semantics
client.from('posts').select('id, comments(count)')                // aggregate

// Database functions (typed from the generated Database["public"]["Functions"]).
// Runs in the caller's RLS transaction. SECURITY DEFINER functions are refused
// unless an operator allowlists them.
client.rpc(fn, args, { get, head, count })

// Auth  → { data, error }
client.auth.signUp({ email, password, username? })
client.auth.signInWithPassword({ email, password })
client.auth.signInWithOtp({ email })            // emails a code
client.auth.verifyOtp({ email, token })         // completes the OTP sign-in
client.auth.signInWithOAuth({ provider, options: { redirectTo, scopes, skipBrowserRedirect } })
client.auth.getSessionFromUrl()                 // finishes the OAuth redirect, see below
client.auth.resetPasswordForEmail(email)
client.auth.resetPassword({ email, otp, password })
client.auth.updateUser({ data, password, currentPassword })
client.auth.setSession({ access_token })
client.auth.signOut()
client.auth.getUser()          // { data: { user } }, revalidates against the server
client.auth.getSession()       // { data: { session } }, local only
client.auth.refreshSession()
client.auth.onAuthStateChange((event, session) => {})  // SIGNED_IN | SIGNED_OUT | TOKEN_REFRESHED
client.auth.deleteUser({ password })            // self-service, needs the backend flow enabled

// Storage → { data, error }. `path` is the object key inside the bucket, and it
// round trips. The file id returned by upload still works anywhere a path does.
client.storage.from(bucket).upload(path, file, { visibility, upsert })
client.storage.from(bucket).download(path)
client.storage.from(bucket).remove([path])
client.storage.from(bucket).list(prefix, { limit, offset, search, sortBy })
client.storage.from(bucket).createSignedUrl(path, expiresIn)     // expiresIn is ignored
client.storage.from(bucket).getPublicUrl(path)                   // sync

// Realtime
client.channel(name, { config: { broadcast: { self, ack }, presence: { key } } })
  .on('postgres_changes', { event, schema, table, filter }, cb)
  .on('broadcast', { event }, cb)
  .on('presence', { event: 'sync' | 'join' | 'leave' }, cb)
  .subscribe(status => {})            // SUBSCRIBED | TIMED_OUT | CLOSED | CHANNEL_ERROR
channel.send({ type: 'broadcast', event, payload })
channel.track(state); channel.untrack(); channel.presenceState()
client.removeChannel(channel)

// Edge functions → { data, error }
client.functions.invoke(name, { body })

Social sign-in (OAuth)

A provider callback is a browser redirect, so it cannot answer the SDK with a session the way a password sign-in does. Two calls, not one:

// 1. Wherever the button lives. Navigates the browser to the backend, which
//    redirects on to the provider.
await client.auth.signInWithOAuth({
  provider: 'google',
  options: { redirectTo: 'https://app.example.com/auth/callback' },
});

// 2. On that page (or on app start). Reads the token the backend appended to
//    the redirect, stores the session, and strips it from the address bar.
const { data, error } = await client.auth.getSessionFromUrl();

getSessionFromUrl() resolves with a null session and no error when there is nothing to read, so calling it on every page load is safe.

Step 1 is a full-page navigation, not a fetch. That is deliberate: the backend sets an OAuth state cookie there and re-checks it on the provider callback, and a cookie set on a cross-site fetch response is refused by Safari and Firefox and by Chrome in incognito. Navigating makes it a first-party cookie, so the flow works in every browser even when your app and your API are on different domains. If you are not using the SDK, point the button at the same URL:

https://<your-api>/api/auth/sign-in/social/google?callbackURL=https://app.example.com/auth/callback

callbackURL is validated against the trusted origins, so it has to be an origin you allowlisted.

Two backend settings decide whether this works at all:

  • Callback URL. Studio, Authentication, Sign In / Providers shows the exact URL to register in the provider's console.
  • CORS allowed origins. Studio, Authentication settings, Security · CORS. If your app is served from a different origin than the API, that origin must be listed. Otherwise the browser refuses the cookie the sign-in start sets, and the provider callback comes back as ?error=state_mismatch.

Storage adapters

| Runtime | Adapter | | -------- | ------------------------------------------------------------- | | Browser | localStorageStorage() or cookieStorage() | | SSR/Node | memoryStorage() (default), serverCookieStorage(cookieMethods) (getAll/setAll, see @motherbase/ssr for Next.js helpers), or a custom { get, set } adapter | | Expo/RN | custom { get, set } backed by AsyncStorage |

import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@motherbase/client';

const client = createClient(API_URL, ANON_KEY, {
  storage: {
    async get() {
      const raw = await AsyncStorage.getItem('session');
      return raw ? JSON.parse(raw) : null;
    },
    async set(session) {
      if (session) await AsyncStorage.setItem('session', JSON.stringify(session));
      else await AsyncStorage.removeItem('session');
    },
  },
});

Compatibility matrix

The matrix is not maintained by hand. It lives in COMPATIBILITY.md and is generated by apps/server/tests/sdk-contract.integration.test.ts, which runs every listed supabase-js call against a live Motherbase server and a live Postgres. Each row carries the id of the test that produced it.

Rows marked Not supported assert the current failure behaviour, so the day a feature lands its test fails and the document has to be regenerated. That is the mechanism that keeps this page from over-claiming (or under-claiming) again. CI regenerates the file and fails if the committed copy differs.

Regenerate it locally with:

npm run test -w apps/server -- sdk-contract

More examples live in docs/sdk.md. Project Studio also exposes a project-specific Connect page with the exact API URL, WebSocket URL and project ID to paste into .env.