mdbase-client
v0.3.0
Published
JavaScript client for mdbase — per-project auth, database and storage. supabase-js compatible API surface.
Maintainers
Readme
mdbase-client
Official JavaScript client for mdbase — per-project auth, database and storage. If you know @supabase/supabase-js, you already know this API.
Zero runtime dependencies. Ships ESM + CommonJS with full TypeScript types (JSDoc examples included). Runs in browsers (evergreen) and Node ≥ 18.
The library mirrors the mdbase public API planes (the tabs of the API reference):
| Tab | Namespace | Base |
| --- | --- | --- |
| Auth API | client.auth | <projectUrl>/auth/v1 |
| Data API (REST) | client.from('table') / client.rest | <projectUrl>/rest/v1 |
| Storage API | client.storage | <projectUrl>/storage/v1 |
A project URL is https://<host>/<ref> — e.g. https://api.mdbase.example.com/my-project. The gateway also answers GET <projectUrl>/health (client.health()).
Two key roles exist: the public key (safe in browsers, sent as apikey on every request) and the base key (service role — bucket administration and unrestricted reads; pass it as the secretKey option, server-side only).
Install
npm install mdbase-clientQuick start
import { createClient } from 'mdbase-client'
const mdbase = createClient(
'https://api.mdbase.example.com/my-project', // project URL
'<public key>' // apikey for every request
)
// Sign up (queues a confirmation email when confirmation is on)
const { data, error } = await mdbase.auth.signUp({
email: '[email protected]',
password: 'a-long-password',
options: { data: { plan: 'pro' } }, // → raw_user_meta_data
})
// Sign in
await mdbase.auth.signInWithPassword({ email: '[email protected]', password: 'a-long-password' })
// Data API — RLS applies to every query as the signed-in user
const { data: tasks, count } = await mdbase
.from('tasks')
.select('id,title,teams(name)', { count: 'exact' })
.eq('done', false)
.order('created_at', { ascending: false })
.limit(50)Coming from Supabase
Method names and the { data, error } result pattern were deliberately kept compatible. The mapping below is the complete list of differences that matter.
Auth
| supabase-js | mdbase-client | Difference |
| --- | --- | --- |
| auth.signUp({ email, password, options: { data } }) | same | adds needsEmailConfirmation in data; session is null until the email is confirmed |
| auth.signInWithPassword | same | — |
| auth.signOut | same | — |
| auth.getUser(jwt?) | same | — |
| auth.updateUser({ data, password }) | auth.updateUser({ user_metadata, password }) | metadata key is user_metadata, not data |
| auth.verifyOtp({ email, token, type: 'signup' }) | auth.verify({ type: 'signup', token }) | token only — no email argument |
| auth.verifyOtp({ type: 'recovery' }) | auth.verify({ type: 'recovery', token }) | recovery signs in; then set the new password with updateUser({ password }) |
| auth.resetPasswordForEmail(email, { redirectTo }) | auth.resetPasswordForEmail(email) | no redirectTo option |
| auth.resend({ email, type }) | same | — |
| auth.refreshSession / setSession / getSession / signOut | same | — |
| auth.onAuthStateChange | same | same event names (SIGNED_IN, TOKEN_REFRESHED, …) |
| auth.signInWithOAuth({ provider, options }) | same | provider is google for now |
| auth.getMfaFactor/MFA, auth.admin | not supported | — |
| — | auth.listSessions(), auth.revokeSession(id), auth.settings(), auth.reauthenticate() | mdbase extras |
Data
| supabase-js | mdbase-client |
| --- | --- |
| .select(columns, { count }) | same — one-level FK embeds use the same teams(name) syntax |
| .eq .neq .gt .gte .lt .lte .like .ilike .in .is .contains .not .or .filter | identical names and semantics |
| .order .limit .offset | same |
| .range(from, to) | same — built from limit/offset |
| .single() / .maybeSingle() | same |
| .insert().select() / .update().select() / .delete().select() | same — mutations return rows only with .select() |
| .upsert() | not supported on rows (object storage has upsert uploads) |
| .rpc() | not supported |
| .textSearch() | not supported |
Storage
| supabase-js | mdbase-client | Difference |
| --- | --- | --- |
| storage.from(bucket) | same | — |
| .upload(path, body, { contentType }) | same | without upsert, an existing path returns 409 conflict |
| .upload(path, body, { upsert: true }) | same | sent as PUT — RLS must allow insert and delete |
| .download(path) → { data: { blob } } | same | returns a Blob in browsers and Node ≥ 18 |
| .list(path, { limit, offset, search }) | .list(prefix, { limit, offset, search }) | no sortBy (results are ordered by path) |
| .remove(paths) | same | returns the server's per-path { name, message? } results |
| .createSignedUrl(path, expiresIn) → data.signedUrl | → data.signed_url | snake_case, plus expires_at |
| .createSignedUrls(paths, expiresIn) | same | per-path { path, signed_url, error } |
| .createSignedUploadUrl(path) + .uploadToSignedUrl(signed, body) | same | signed uploads are headerless and overwrite |
| .getPublicUrl(path) → data.publicUrl | same | URL points at /object/public/… |
| .copy(from, to) / .move(from, to) | same | references use bucketId (uuid) + path |
| .updateBucket(id, options) / .deleteBucket(id) | same | buckets are addressed by id (uuid), not name |
| — | .emptyBucket(id) | removes every object; the bucket stays |
| — | .getInfo(path) | object metadata without downloading |
| — | getRenderUrl(path, transform, variant) | /render/image/… resize/reformat endpoints |
| storage.from(bucket).createBucket etc. | client.storage.createBucket… | bucket admin lives on client.storage; needs the base key (secretKey) |
Not supported (by design)
The mdbase gateway does not expose these endpoints yet, so the client does not pretend they exist — calling them fails fast instead of failing silently:
| Missing method | Why | What to do instead |
| --- | --- | --- |
| .upsert(values) on rows | the Data API has no upsert resolution | insert and handle 409/23505, or filter-update then insert what failed |
| .rpc(name) | no stored-procedure endpoint | use the dashboard's SQL runner for server-side logic, or a Data API table as a queue |
| .textSearch(col, q) | no endpoint support | like('col', '*q*') or ilike |
| auth.signInWithOtp | no magic-link user endpoint yet | password + resetPasswordForEmail |
| realtime channels, edge functions | no endpoints | poll with select + order/limit, or run server-side code in your app |
| Control plane (projects, keys, SQL runner) | dashboard-only — removed from the public API reference | drive it from the dashboard |
When a new endpoint ships, the client gains the matching method in a minor release.
Auth API (client.auth)
| Method | Endpoint |
| --- | --- |
| auth.signUp({ email, password, options: { data } }) | POST /auth/v1/signup |
| auth.verify({ type, token }) | POST /auth/v1/verify |
| auth.resend({ email, type? }) | POST /auth/v1/resend |
| auth.signInWithPassword({ email, password }) | POST /auth/v1/token?grant_type=password |
| auth.refreshSession(refreshToken?) | POST /auth/v1/token?grant_type=refresh_token |
| auth.signOut() | POST /auth/v1/logout |
| auth.getUser(jwt?) | GET /auth/v1/user |
| auth.updateUser({ password?, user_metadata? }) | PUT /auth/v1/user |
| auth.resetPasswordForEmail(email) | POST /auth/v1/recover |
| auth.reauthenticate() | GET /auth/v1/reauthenticate |
| auth.settings() | GET /auth/v1/settings |
| auth.listSessions() | GET /auth/v1/sessions |
| auth.revokeSession(id) | DELETE /auth/v1/sessions/:id |
| auth.signInWithOAuth({ provider, options }) | GET /auth/v1/authorize?provider=… |
| auth.getSessionFromUrl() | consumes the OAuth/verify callback fragment |
signUp resolves { user, session, needsEmailConfirmation }: when the project requires email confirmation, session is null and needsEmailConfirmation is true; with confirmation disabled, a session comes back immediately. Signing up again for an email that already belongs to a confirmed user returns the user_already_exists error (unconfirmed duplicates simply re-queue the email).
Confirmation tokens are valid for 24 hours, recovery tokens for 1 hour. The recovery flow is three steps: resetPasswordForEmail(email) → verify({ type: 'recovery', token }) (revokes all other sessions and signs in) → updateUser({ password }). GET /auth/v1/verify?type=…&token=…&redirect_to=… is the browser-link variant — it redirects to your app with the session in the URL fragment, which the client picks up automatically (detectSessionInUrl).
Refresh tokens are single-use — replaying one triggers token_reuse_detected, revokes the whole session, and the client signs out locally with a SIGNED_OUT event. Session management is automatic: persistence (localStorage in browsers, memory in Node, pluggable adapter), refresh shortly before expiry, cross-tab sync, and state events via auth.onAuthStateChange(cb). auth.settings() exposes the project's public auth configuration (email_confirmation_required, cookie_mode, external providers) and auth.reauthenticate() confirms the session is live before sensitive operations.
Data API — REST (client.from, tab "Data API (REST)")
PostgREST-style queries over the project's public tables. Every statement runs as the caller's role — anon without a session, the signed-in user via the bearer token — so Row Level Security decides every row.
Reading
// Column picks + one-level FK embeds (parent → object, child → array)
await mdbase.from('tasks').select('id,title,teams(name),owner(*)')
// Filters — col=op.value syntax is generated for you
.eq('done', false) // done=eq.false
.neq('priority', 9).gt('priority', 0) // also gte lt lte
.like('title', '*ship*') // * wildcards; ilike for case-insensitive
.in('owner_id', ['u1', 'u2']) // owner_id=in.(u1,u2)
.is('due', null) // due=is.null|true|false
.contains('tags', ['a']) // jsonb/array containment
.not('done', 'eq', true) // done=not.eq.true
.or('done.eq.true,priority.in.(1,2)') // or=(…) groups
// Ordering & paging (limit is clamped to the project's max_rows server-side)
.order('created_at', { ascending: false, nullsFirst: false })
.order('title') // multi-order joins into one param
.range(20, 29) // or .limit(n).offset(n)
// Modifiers
.single() // exactly one row as an object (406 PGRST116 otherwise)
.maybeSingle() // first row or null (adds limit 1)
const { data, count, error, status } = await resultcount: 'exact' (also 'planned' | 'estimated') on select adds Prefer: count=exact and parses the Content-Range total:
const { data, count } = await mdbase.from('tasks').select('*', { count: 'exact' }).limit(1)Writing
// Insert — one row or a batch. 201; rows come back only with .select()
await mdbase.from('tasks').insert({ title: 'ship it', done: false })
const { data } = await mdbase.from('tasks').insert([{ title: 'a' }, { title: 'b' }]).select()
// Update — filters are mandatory (the API rejects full-table updates)
await mdbase.from('tasks').update({ done: true }).eq('id', id).select()
// Delete — filters are mandatory
await mdbase.from('tasks').delete().eq('done', true).select()The builder is a thenable — await it directly. Results are { data, error, count, status, statusText }; API errors resolve into error (ApiError with .code like PGRST105, 42501, 23505) and never throw.
Storage API (client.storage, tab "Storage API")
Objects run under the caller's gateway role (public buckets are world-readable via headerless /object/public/… URLs, users own their uploads). Bucket administration requires the base key — pass it server-side only:
const mdbase = createClient(projectUrl, publicKey, { secretKey: baseKey }) // server-side!// Buckets — created once, then addressed by their uuid `id`
const bucket = await mdbase.storage.createBucket('media', {
public: true,
fileSizeLimit: 1024 * 1024,
allowedMimeTypes: ['image/*', 'application/pdf'],
})
await mdbase.storage.listBuckets() // signed-in users or base key
await mdbase.storage.getBucket(bucket.data.id)
await mdbase.storage.updateBucket(bucket.data.id, { public: false })
await mdbase.storage.emptyBucket(bucket.data.id) // remove every object
await mdbase.storage.deleteBucket(bucket.data.id) // only when empty
// Objects
const media = mdbase.storage.from('media')
await media.upload('img/cat.png', fileBlob, { contentType: 'image/png' })
await media.upload('img/cat.png', fileBlob, { contentType: 'image/png', upsert: true }) // PUT overwrite
// (multipart: pass a FormData with a `file` part)
const { data } = await media.download('img/cat.png') // { blob } — Blob in browsers & Node
await media.getInfo('img/cat.png') // metadata without downloading
await media.list('img/2026/', { limit: 50, search: 'cat' }) // prefix + substring search
await media.remove(['img/cat.png', 'img/old.png']) // one bulk call; per-path { name, message? }
await media.createSignedUrl('docs/report.pdf', 3600) // standalone URL, no headers needed
await media.createSignedUrls(['a.txt', 'b.txt'], 3600) // bulk — per-path results
const signed = await media.createSignedUploadUrl('up/report.txt')
await media.uploadToSignedUrl(signed.data, blob) // headerless PUT, overwrites
media.getPublicUrl('img/cat.png') // sync URL for public buckets
// Copy / move (references use the bucket id + path)
await mdbase.storage.copy(
{ bucketId: bucket.data.id, path: 'docs/a.pdf' },
{ path: 'docs/a-copy.pdf' } // destination bucketId defaults to source
)
await mdbase.storage.move(
{ bucketId: bucket.data.id, path: 'docs/a.pdf' },
{ bucketId: otherId, path: 'renamed.pdf' }
)
// Image transforms (/render/image/… — resize, reformat, quality)
const { data: url } = media.getRenderUrl('img/cat.png', { width: 300, format: 'webp' }) // public
const { data: authed } = media.getRenderUrl('p/x.png', { width: 300 }, { variant: 'authenticated' }) // private
media.getRenderUrl('p/x.png', { width: 300 }, { sign: signed.signed_url }) // via signed URLErrors keep the documented codes: validation_error, conflict, limit_exceeded (402/413), not_found (RLS-hidden objects 404 like the gateway), rate_limited (20 uploads/min per IP+project).
Cookie mode
When your project is configured for cookie sessions, the server keeps tokens in httpOnly cookies (mdb_at_<ref> / mdb_rt_<ref>) and refreshes them itself. The browser needs no JS-visible tokens — and auth, data and storage requests all switch to credentials: include:
const mdbase = createClient(projectUrl, apiKey, { cookieMode: true })
await mdbase.auth.getUser() // authenticated via cookies
await mdbase.from('tasks').select() // cookies ride along; RLS as the userIn cookie mode the client sends no Authorization header, stores nothing, and getSession() always resolves null.
Deploying behind an API subdomain (CORS)
In development everything is same-origin and CORS never appears. In production, apps commonly live on app.example.com while the gateway serves api.mdbase.example.com — that makes every client request cross-origin, so the gateway must:
- Answer
OPTIONSpreflights forGET/POST/PATCH/PUT/DELETEwithAccess-Control-Allow-Origin: <exact app origin>(never*when cookies are involved),Access-Control-Allow-Credentials: true(only needed for cookie mode),Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS, andAccess-Control-Allow-Headers: apikey, authorization, content-type(apikeyandauthorizationare non-simple headers, so every request triggers a preflight). - Send
Access-Control-Expose-Headers: Content-Range, ETagon real responses — without this,{ count: 'exact' }silently resolvesnullin browsers, because JavaScript cannot read theContent-Rangeheader cross-origin unless it is exposed.
Serving the API on the same host as the app (a path prefix like /my-project) avoids CORS entirely.
Options
createClient(projectUrl, apiKey, {
secretKey, // server-side only: the base key (bucket admin, unrestricted reads)
autoRefreshToken: true, // rotate the refresh token before expiry
persistSession: true, // store the session in `storage`
detectSessionInUrl: true, // consume OAuth/verify fragments on load
refreshMarginSeconds: 60, // refresh this long before expiry
cookieMode: false, // httpOnly-cookie sessions
storage, // { getItem, setItem, removeItem } adapter
storageKey, // default `mdbase.auth.<projectRef>`
credentials: 'same-origin', // fetch credentials policy
headers: {}, // extra headers on every request
fetch, // custom fetch implementation
})Error codes
Methods never throw for API errors — they resolve { data, error }. error is an ApiError with .status and a machine-readable .code. The full set, by plane:
Client-side: network_error (status 0) — request never reached the gateway (offline, TLS, CORS block).
Gateway / Auth
| code | status | meaning |
| --- | --- | --- |
| missing_api_key / invalid_api_key | 401 | apikey header absent or wrong |
| project_not_found | 404 | project ref does not exist |
| project_paused / project_deleting | 503 | project temporarily unavailable |
| validation_error | 400 | bad email, short password, bad input |
| user_already_exists | 400 | signup for an email that already belongs to a confirmed user |
| limit_exceeded | 402 | project plan limit (max users, storage quota) |
| rate_limited | 429 | signup/verify/resend/recover 5/min, sign-in 30/min — per IP+project |
| invalid_grant | 400 | wrong credentials, or revoked/expired refresh token |
| email_not_confirmed | 400 | sign-in before confirming the email |
| token_reuse_detected | 400 | a used refresh token was replayed — session revoked |
| invalid_token | 400/401 | unknown, already-used or expired token, or revoked session |
| unauthorized | 401 | no bearer/cookie where one is required |
| oauth_not_configured / unsupported provider / invalid_oauth_state / oauth_provider_error | 400 | OAuth setup/callback problems |
Data API (Postgres codes pass through)
| code | status | meaning |
| --- | --- | --- |
| PGRST105 | 400 | update/delete without a filter |
| PGRST116 | 406 | .single() matched 0 or >1 rows |
| PGRST100 / PGRST102 / PGRST200 | 400 | bad filter syntax / bad body / bad embed |
| 42P01 / 42703 | 404 | unknown table / unknown column |
| 42501 | 403 | RLS denied the operation for this caller |
| 22P02 / 23514 / 42883 | 400 | bad value cast / check violation / wrong operator type |
| 23505 / 23503 | 409 | unique violation / foreign-key violation |
Storage
| code | status | meaning |
| --- | --- | --- |
| validation_error | 400/422 | invalid path, missing body, MIME not allowed |
| not_found | 404 | object missing or hidden from this caller by RLS |
| forbidden | 403 | non-public bucket on a public route / RLS denial |
| conflict | 409 | path already exists / bucket not empty |
| limit_exceeded | 402 / 413 | project storage quota / object size cap |
| rate_limited | 429 | 20 uploads/min per IP+project |
| invalid_token | 401 | expired or tampered signed URL |
Node / SSR
Sessions live in memory by default on the server; persist them yourself (e.g. in a cookie) and restore per request:
const mdbase = createClient(projectUrl, apiKey)
await mdbase.auth.setSession({ access_token, refresh_token }) // from your cookie
const { data: { user } } = await mdbase.auth.getUser()
const rows = await mdbase.from('tasks').select() // now acts as that userProject layout
src/
MdbaseClient.ts client shell: namespaces, URL resolution, health()
auth/ Auth API tab
data/ Data API tab — PostgREST query builder
storage/ Storage API tab — buckets + objects + renders
lib/ shared HTTP transport, storage adapters, helpers, errors
docs/
api-reference-data-storage-control.md transcribed API reference
llms.txt index for AI assistants
CHANGELOG.md release notesDevelopment
npm install
npm run build # dist/esm + dist/cjs (+ type declarations)
npm test # build, then node:test suites against an in-memory mock serverThe test suites (test/) spin up an in-memory mock of the mdbase API — auth confirmation + recovery flows, single-use refresh tokens with reuse detection, PostgREST filtering/paging, the supabase-style storage surface (bucket ids, bulk deletes, signed uploads, copy/move, render params) — and assert both generated requests and end-to-end behavior.
License
MIT
