@motherbase/client 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +379 -284
  2. package/package.json +1 -1
  3. package/dist/admin.d.ts +0 -81
  4. package/dist/admin.d.ts.map +0 -1
  5. package/dist/admin.js +0 -85
  6. package/dist/admin.js.map +0 -1
  7. package/dist/auth.d.ts +0 -362
  8. package/dist/auth.d.ts.map +0 -1
  9. package/dist/auth.js +0 -721
  10. package/dist/auth.js.map +0 -1
  11. package/dist/channel.d.ts +0 -122
  12. package/dist/channel.d.ts.map +0 -1
  13. package/dist/channel.js +0 -213
  14. package/dist/channel.js.map +0 -1
  15. package/dist/compat.d.ts +0 -40
  16. package/dist/compat.d.ts.map +0 -1
  17. package/dist/compat.js +0 -98
  18. package/dist/compat.js.map +0 -1
  19. package/dist/cookie.d.ts +0 -68
  20. package/dist/cookie.d.ts.map +0 -1
  21. package/dist/cookie.js +0 -152
  22. package/dist/cookie.js.map +0 -1
  23. package/dist/database.d.ts +0 -188
  24. package/dist/database.d.ts.map +0 -1
  25. package/dist/database.js +0 -2
  26. package/dist/database.js.map +0 -1
  27. package/dist/devices.d.ts +0 -13
  28. package/dist/devices.d.ts.map +0 -1
  29. package/dist/devices.js +0 -13
  30. package/dist/devices.js.map +0 -1
  31. package/dist/edge.d.ts +0 -7
  32. package/dist/edge.d.ts.map +0 -1
  33. package/dist/edge.js +0 -13
  34. package/dist/edge.js.map +0 -1
  35. package/dist/functions.d.ts +0 -60
  36. package/dist/functions.d.ts.map +0 -1
  37. package/dist/functions.js +0 -75
  38. package/dist/functions.js.map +0 -1
  39. package/dist/http.d.ts +0 -123
  40. package/dist/http.d.ts.map +0 -1
  41. package/dist/http.js +0 -205
  42. package/dist/http.js.map +0 -1
  43. package/dist/index.d.ts +0 -185
  44. package/dist/index.d.ts.map +0 -1
  45. package/dist/index.js +0 -331
  46. package/dist/index.js.map +0 -1
  47. package/dist/mfa.d.ts +0 -109
  48. package/dist/mfa.d.ts.map +0 -1
  49. package/dist/mfa.js +0 -134
  50. package/dist/mfa.js.map +0 -1
  51. package/dist/postgrest.d.ts +0 -326
  52. package/dist/postgrest.d.ts.map +0 -1
  53. package/dist/postgrest.js +0 -629
  54. package/dist/postgrest.js.map +0 -1
  55. package/dist/profile.d.ts +0 -16
  56. package/dist/profile.d.ts.map +0 -1
  57. package/dist/profile.js +0 -26
  58. package/dist/profile.js.map +0 -1
  59. package/dist/query.d.ts +0 -97
  60. package/dist/query.d.ts.map +0 -1
  61. package/dist/query.js +0 -94
  62. package/dist/query.js.map +0 -1
  63. package/dist/realtime.d.ts +0 -114
  64. package/dist/realtime.d.ts.map +0 -1
  65. package/dist/realtime.js +0 -372
  66. package/dist/realtime.js.map +0 -1
  67. package/dist/rest.d.ts +0 -50
  68. package/dist/rest.d.ts.map +0 -1
  69. package/dist/rest.js +0 -75
  70. package/dist/rest.js.map +0 -1
  71. package/dist/session.d.ts +0 -47
  72. package/dist/session.d.ts.map +0 -1
  73. package/dist/session.js +0 -136
  74. package/dist/session.js.map +0 -1
  75. package/dist/storage.d.ts +0 -239
  76. package/dist/storage.d.ts.map +0 -1
  77. package/dist/storage.js +0 -391
  78. package/dist/storage.js.map +0 -1
  79. package/dist/testing.d.ts +0 -38
  80. package/dist/testing.d.ts.map +0 -1
  81. package/dist/testing.js +0 -50
  82. package/dist/testing.js.map +0 -1
  83. package/dist/types.d.ts +0 -343
  84. package/dist/types.d.ts.map +0 -1
  85. package/dist/types.js +0 -2
  86. package/dist/types.js.map +0 -1
  87. package/dist/warn.d.ts +0 -4
  88. package/dist/warn.d.ts.map +0 -1
  89. package/dist/warn.js +0 -27
  90. package/dist/warn.js.map +0 -1
package/README.md CHANGED
@@ -1,284 +1,379 @@
1
- # @motherbase/client
2
-
3
- A **supabase-js-shaped** client SDK for the Motherbase self-hosted backend. It
4
- keeps the same entry points (`createClient`, `.from()`, `.auth`, `.storage`,
5
- `.channel()`, `.functions`) and the same `{ data, error }` result convention, so
6
- supabase-js code reads and reviews the same way here.
7
-
8
- **Scope, honestly.** For the **data layer**, this is a package swap: change the
9
- import and the client factory, keep your queries. Every `supabase-js` data call
10
- in the contract suite is either supported or supported with a stated difference,
11
- including relational embedding (`select('*, author(*)')`), `.rpc()`, native
12
- `upsert`, `count`, `csv()`, the full filter set and PostgREST error codes.
13
- Auth and realtime are close, with named gaps (no PKCE code exchange, no
14
- `generateLink`, no private channels). Two-factor (`client.auth.mfa.*`), SSO,
15
- linked identities and secret-key user administration (`client.auth.admin.*`) are
16
- all here.
17
-
18
- **Storage is path-addressed too**: `upload('avatars/123/pic.png')` then
19
- `download('avatars/123/pic.png')` round trips, `list(prefix)` works, and keys are
20
- traversal-checked. `copy`, `move`, `info`, `exists`, batch signed URLs and
21
- direct-to-storage signed uploads are supported; image transforms are not.
22
- Authorization is ownership plus bucket tokens by default, and real RLS policies on
23
- `storage.files` when the `storage.policies` setting is on. See
24
- [`docs/migrating-from-supabase.md`](../../docs/migrating-from-supabase.md).
25
-
26
- Do not plan a migration from the paragraph above. Plan it from
27
- [`COMPATIBILITY.md`](./COMPATIBILITY.md), which is generated from contract tests
28
- and is exhaustive, per method, with the exact difference for every partial row.
29
-
30
- Typed, isomorphic (browser, Next.js server & client, Expo / React Native), built
31
- on global `fetch` with zero runtime dependencies (`socket.io-client` is an
32
- optional peer dependency, used only for realtime).
33
-
34
- ## Install
35
-
36
- ```bash
37
- npm i @motherbase/client
38
- # optional, only if you use realtime:
39
- npm i socket.io-client
40
- ```
41
-
42
- ## Quickstart
43
-
44
- ```ts
45
- import { createClient, localStorageStorage } from '@motherbase/client';
46
-
47
- const client = createClient(
48
- 'http://localhost:5001',
49
- 'your-anon-key', // optional; sent as an `apikey` header
50
- { storage: localStorageStorage() } // omit for in-memory (SSR-safe) sessions
51
- );
52
-
53
- await client.hydrate(); // restore a persisted session (optional)
54
-
55
- // Auth
56
- await client.auth.signUp({ email: 'me@example.com', password: 'secret123' });
57
- const { data, error } = await client.auth.signInWithPassword({
58
- email: 'me@example.com',
59
- password: 'secret123',
60
- });
61
-
62
- // Query builder (awaitable, PostgREST-style)
63
- const { data: products } = await client
64
- .from('products')
65
- .select('id,name,price')
66
- .lt('price', 50)
67
- .order('name', { ascending: false })
68
- .limit(20);
69
-
70
- // Storage. `uploaded.path` is the key you passed, so later download/remove/
71
- // signed-URL calls take the same string.
72
- const { data: uploaded } = await client.storage.from('avatars').upload(`${userId}/me.png`, myBlob, {
73
- visibility: 'public',
74
- });
75
-
76
- // Realtime: postgres_changes, broadcast, presence over one channel (Supabase-shaped)
77
- const room = client
78
- .channel('room-1', { config: { broadcast: { self: true }, presence: { key: userId } } })
79
- .on('postgres_changes', { event: '*', schema: 'public', table: 'messages', filter: 'room_id=eq.42' }, p => {
80
- console.log(p.eventType, p.new, p.old);
81
- })
82
- .on('broadcast', { event: 'cursor' }, ({ payload }) => console.log(payload))
83
- .on('presence', { event: 'sync' }, () => console.log(room.presenceState()))
84
- .subscribe();
85
-
86
- await room.send({ type: 'broadcast', event: 'cursor', payload: { x: 1, y: 2 } });
87
- await room.track({ online_at: Date.now() });
88
-
89
- // Edge functions
90
- const { data: result } = await client.functions.invoke('hello', { body: { name: 'Ada' } });
91
- ```
92
-
93
- Every data/auth/storage call resolves to `{ data, error }` and **never throws**
94
- for expected API errors. `error` is `null` on success, otherwise
95
- `{ message, status?, code? }`.
96
-
97
- The SDK attaches the access token automatically and revalidates it on a 401
98
- (single-flight: concurrent requests share one revalidation). Sessions are Better
99
- Auth bearer sessions, so there is no separate refresh token to rotate: when the
100
- token is no longer valid the SDK clears the session and emits `SIGNED_OUT`.
101
-
102
- ## API surface (cheatsheet)
103
-
104
- ```ts
105
- // Data. Filters: eq neq gt gte lt lte like ilike likeAllOf likeAnyOf ilikeAllOf
106
- // ilikeAnyOf in is not or filter match textSearch contains containedBy overlaps
107
- // rangeGt rangeGte rangeLt rangeLte rangeAdjacent
108
- client.from(table).select(cols).eq(col, v).in(col, [..]).order(col, { ascending, nullsFirst, referencedTable })
109
- client.from(table).select(cols).limit(n, { referencedTable }).range(from, to).abortSignal(signal)
110
- client.from(table).select(cols).or('a.eq.1,and(b.gt.2,c.is.null)') // nests, as PostgREST does
111
- client.from(table).select(cols).not(col, 'eq', v).filter(col, 'isdistinct', v)
112
- client.from(table).select(cols).textSearch(col, q, { type: 'websearch', config: 'english' })
113
- client.from(table).select(cols).single() | .maybeSingle() | .returns<T>() | .csv() | .geojson() | .explain()
114
- client.from(table).update(v).eq(..).throwOnError() | .maxAffected(1) | .rollback()
115
- client.from(table).insert(row) | .insert([rowA, rowB], { defaultToNull: false })
116
- client.from(table).upsert(rows, { onConflict, ignoreDuplicates })
117
- client.from(table).update(values).eq(..) // a filter is required
118
- client.from(table).delete().eq(..) // a filter is required
119
- // Every call resolves { data, error, count }. `count` is null unless you ask:
120
- // .select('*', { count: 'exact', head: true }).
121
- // Values are quoted for you: .in('name', ['a,b']) is one value, not two.
122
- // Writes always return their rows: no .select() needed, and no way to opt out.
123
- // An unbounded select is capped at 100 rows server side: paginate with .range().
124
- // explain() needs an admin caller; a normal caller gets 403.
125
-
126
- // Relational embedding. Compiles to a correlated LATERAL subquery inside the
127
- // same RLS transaction, so embedded rows are policy-filtered too.
128
- client.from('posts').select('id, author(name), comments(id)') // object / array by FK shape
129
- client.from('posts').select('id, author!inner(name)') // inner join semantics
130
- client.from('posts').select('id, comments(count)') // aggregate
131
-
132
- // Database functions (typed from the generated Database["public"]["Functions"]).
133
- // Runs in the caller's RLS transaction. SECURITY DEFINER functions are refused
134
- // unless an operator allowlists them.
135
- client.rpc(fn, args, { get, head, count })
136
-
137
- // Auth → { data, error }
138
- client.auth.signUp({ email, password, username? })
139
- client.auth.signInWithPassword({ email, password })
140
- client.auth.signInWithOtp({ email }) // emails a code
141
- client.auth.verifyOtp({ email, token }) // completes the OTP sign-in
142
- client.auth.signInWithOAuth({ provider, options: { redirectTo, scopes, skipBrowserRedirect } })
143
- client.auth.getSessionFromUrl() // finishes the OAuth redirect, see below
144
- client.auth.resetPasswordForEmail(email)
145
- client.auth.resetPassword({ email, otp, password })
146
- client.auth.updateUser({ data, password, currentPassword })
147
- client.auth.setSession({ access_token })
148
- client.auth.signOut()
149
- client.auth.getUser() // { data: { user } }, revalidates against the server
150
- client.auth.getSession() // { data: { session } }, local only
151
- client.auth.refreshSession()
152
- client.auth.onAuthStateChange((event, session) => {}) // SIGNED_IN | SIGNED_OUT | TOKEN_REFRESHED
153
- client.auth.deleteUser({ password }) // self-service, needs the backend flow enabled
154
- client.auth.signInWithSSO({ domain | providerId | email })
155
- client.auth.signInWithIdToken({ provider, token, nonce }) // Apple / Google One Tap
156
- client.auth.getUserIdentities() | .linkIdentity({ provider }) | .unlinkIdentity({ provider, id })
157
- client.auth.getClaims() // { claims, token }, decoded from a minted JWT
158
- client.auth.startAutoRefresh(ms) | .stopAutoRefresh()
159
- client.auth.mfa.enroll({ password }) | .challenge({ channel }) | .verify({ code, type? })
160
- client.auth.mfa.listFactors() | .regenerateBackupCodes({ password }) | .unenroll({ password })
161
- client.auth.admin.* // secret key only: list/get/create/update/delete/signOut/invite
162
-
163
- // Storage → { data, error }. `path` is the object key inside the bucket, and it
164
- // round trips. The file id returned by upload still works anywhere a path does.
165
- client.storage.from(bucket).upload(path, file, { visibility, upsert })
166
- client.storage.from(bucket).download(path)
167
- client.storage.from(bucket).remove([path])
168
- client.storage.from(bucket).list(prefix, { limit, offset, search, sortBy })
169
- client.storage.from(bucket).createSignedUrl(path, expiresIn) // seconds, 1..604800
170
- client.storage.from(bucket).createSignedUrls([path], expiresIn) // per-entry results
171
- client.storage.from(bucket).getPublicUrl(path) // sync
172
- client.storage.from(bucket).update(path, file) // upload with upsert on
173
- client.storage.from(bucket).copy(from, to, { upsert }) | .move(from, to, { upsert })
174
- client.storage.from(bucket).info(path) | .exists(path) // 404 reads as false
175
- client.storage.from(bucket).createSignedUploadUrl(path, { size, contentType }) // S3 driver
176
- client.storage.from(bucket).uploadToSignedUrl(path, token, file)
177
-
178
- // Realtime
179
- client.channel(name, { config: { broadcast: { self, ack }, presence: { key } } })
180
- .on('postgres_changes', { event, schema, table, filter }, cb)
181
- .on('broadcast', { event }, cb)
182
- .on('presence', { event: 'sync' | 'join' | 'leave' }, cb)
183
- .subscribe(status => {}) // SUBSCRIBED | TIMED_OUT | CLOSED | CHANNEL_ERROR
184
- channel.send({ type: 'broadcast', event, payload })
185
- channel.track(state); channel.untrack(); channel.presenceState()
186
- client.removeChannel(channel)
187
-
188
- // Edge functions → { data, error }. `error` is a FunctionsHttpError /
189
- // FunctionsFetchError instance, but invoke still resolves rather than throws.
190
- client.functions.invoke(name, { body, method, headers, auth })
191
- ```
192
-
193
- ## Social sign-in (OAuth)
194
-
195
- A provider callback is a browser redirect, so it cannot answer the SDK with a
196
- session the way a password sign-in does. Two calls, not one:
197
-
198
- ```ts
199
- // 1. Wherever the button lives. Navigates the browser to the backend, which
200
- // redirects on to the provider.
201
- await client.auth.signInWithOAuth({
202
- provider: 'google',
203
- options: { redirectTo: 'https://app.example.com/auth/callback' },
204
- });
205
-
206
- // 2. On that page (or on app start). Reads the token the backend appended to
207
- // the redirect, stores the session, and strips it from the address bar.
208
- const { data, error } = await client.auth.getSessionFromUrl();
209
- ```
210
-
211
- `getSessionFromUrl()` resolves with a null session and no error when there is
212
- nothing to read, so calling it on every page load is safe.
213
-
214
- Step 1 is a full-page navigation, not a fetch. That is deliberate: the backend
215
- sets an OAuth state cookie there and re-checks it on the provider callback, and
216
- a cookie set on a cross-site fetch response is refused by Safari and Firefox and
217
- by Chrome in incognito. Navigating makes it a first-party cookie, so the flow
218
- works in every browser even when your app and your API are on different domains.
219
- If you are not using the SDK, point the button at the same URL:
220
-
221
- ```
222
- https://<your-api>/api/auth/sign-in/social/google?callbackURL=https://app.example.com/auth/callback
223
- ```
224
-
225
- `callbackURL` is validated against the trusted origins, so it has to be an
226
- origin you allowlisted.
227
-
228
- Two backend settings decide whether this works at all:
229
-
230
- - **Callback URL.** Studio, Authentication, Sign In / Providers shows the exact
231
- URL to register in the provider's console.
232
- - **CORS allowed origins.** Studio, Authentication settings, Security · CORS.
233
- If your app is served from a different origin than the API, that origin must
234
- be listed. Otherwise the browser refuses the cookie the sign-in start sets,
235
- and the provider callback comes back as `?error=state_mismatch`.
236
-
237
- ## Storage adapters
238
-
239
- | Runtime | Adapter |
240
- | -------- | ------------------------------------------------------------- |
241
- | Browser | `localStorageStorage()` or `cookieStorage()` |
242
- | SSR/Node | `memoryStorage()` (default), `serverCookieStorage(cookieMethods)` (getAll/setAll, see `@motherbase/ssr` for Next.js helpers), or a custom `{ get, set }` adapter |
243
- | Expo/RN | custom `{ get, set }` backed by `AsyncStorage` |
244
-
245
- ```ts
246
- import AsyncStorage from '@react-native-async-storage/async-storage';
247
- import { createClient } from '@motherbase/client';
248
-
249
- const client = createClient(API_URL, ANON_KEY, {
250
- storage: {
251
- async get() {
252
- const raw = await AsyncStorage.getItem('session');
253
- return raw ? JSON.parse(raw) : null;
254
- },
255
- async set(session) {
256
- if (session) await AsyncStorage.setItem('session', JSON.stringify(session));
257
- else await AsyncStorage.removeItem('session');
258
- },
259
- },
260
- });
261
- ```
262
-
263
- ## Compatibility matrix
264
-
265
- The matrix is **not maintained by hand**. It lives in
266
- [`COMPATIBILITY.md`](./COMPATIBILITY.md) and is generated by
267
- `apps/server/tests/sdk-contract.integration.test.ts`, which runs every listed
268
- supabase-js call against a live Motherbase server and a live Postgres. Each row
269
- carries the id of the test that produced it.
270
-
271
- Rows marked `Not supported` assert the current failure behaviour, so the day a
272
- feature lands its test fails and the document has to be regenerated. That is the
273
- mechanism that keeps this page from over-claiming (or under-claiming) again. CI
274
- regenerates the file and fails if the committed copy differs.
275
-
276
- Regenerate it locally with:
277
-
278
- ```bash
279
- npm run test -w apps/server -- sdk-contract
280
- ```
281
-
282
- More examples live in [`docs/sdk.md`](../../docs/sdk.md). Project Studio also
283
- exposes a project-specific Connect page with the exact API URL, WebSocket URL and
284
- project ID to paste into `.env`.
1
+ # @motherbase/client
2
+
3
+ A **supabase-js-shaped** client SDK for the Motherbase self-hosted backend. It
4
+ keeps the same entry points (`createClient`, `.from()`, `.auth`, `.storage`,
5
+ `.channel()`, `.functions`) and the same `{ data, error }` result convention, so
6
+ supabase-js code reads and reviews the same way here.
7
+
8
+ **Scope, honestly.** For the **data layer**, this is a package swap: change the
9
+ import and the client factory, keep your queries. Every `supabase-js` data call
10
+ in the contract suite is either supported or supported with a stated difference,
11
+ including relational embedding (`select('*, author(*)')`), `.rpc()`, native
12
+ `upsert`, `count`, `csv()`, the full filter set and PostgREST error codes.
13
+ Auth and realtime are close, with named gaps (no private channels). Two-factor
14
+ (`client.auth.mfa.*`), SSO, anonymous sign-ins, CAPTCHA tokens, the PKCE flow
15
+ (`flowType: 'pkce'` + `exchangeCodeForSession`), linked identities and
16
+ secret-key user administration (`client.auth.admin.*`, including `generateLink`
17
+ and `admin.mfa`) are all here, and so are what supabase-js lacks: organizations
18
+ and teams (`client.auth.organization.*`), passkeys and runtime SSO providers. You
19
+ never import Better Auth on the client.
20
+
21
+ **Storage is path-addressed too**: `upload('avatars/123/pic.png')` then
22
+ `download('avatars/123/pic.png')` round trips, `list(prefix)` works, and keys are
23
+ traversal-checked. `copy`, `move`, `info`, `exists`, batch signed URLs and
24
+ direct-to-storage signed uploads are supported; image transforms are not.
25
+ Authorization is ownership plus bucket tokens by default, and real RLS policies on
26
+ `storage.files` when the `storage.policies` setting is on. See
27
+ [`docs/migrating-from-supabase.md`](../../docs/migrating-from-supabase.md).
28
+
29
+ On the server, `createClient(url, secretKey)` also authorizes storage objects as
30
+ `service_role`, including objects uploaded by another user. Secret-key
31
+ impersonation keeps the permissions of the impersonated caller. Anonymous uploads
32
+ require storage policies granting INSERT; they do not require SELECT. An upload
33
+ response's `url` can be `null` when the caller cannot read the object.
34
+
35
+ Uploads accept `Blob`, `ArrayBuffer`, and typed arrays, including Node `Buffer`.
36
+ Signed uploads carry a scoped finalization token; keep the entire returned token
37
+ when calling `uploadToSignedUrl`. This capability grants finalization, not private
38
+ read access. Public URLs use the shared contracts route.
39
+
40
+ For auth mutations in Node, the SDK supplies the API URL's origin when no
41
+ `Origin` header was provided. An explicit `headers: { Origin: 'https://app.example' }`
42
+ is preserved and must be trusted by the server. Browser requests retain the
43
+ browser's Origin and the server's CSRF checks remain enabled.
44
+
45
+ Do not plan a migration from the paragraph above. Plan it from
46
+ [`COMPATIBILITY.md`](./COMPATIBILITY.md), which is generated from contract tests
47
+ and is exhaustive, per method, with the exact difference for every partial row.
48
+
49
+ **Write responses (Supabase semantics).** Native and compatibility clients,
50
+ including SSR clients, return `data: null` after a successful write unless you
51
+ chain `.select()`. For a public contact form, grant INSERT and create an INSERT
52
+ policy; no SELECT policy is needed. Requesting rows also requires SELECT access.
53
+
54
+ ```ts
55
+ // Write only: no SELECT access required.
56
+ const { error } = await client.from('contacts').insert({ email, message });
57
+
58
+ // Read back the stored row, including generated defaults, if RLS permits it.
59
+ const { data } = await client.from('contacts').insert({ email, message }).select().single();
60
+ ```
61
+
62
+ Existing Motherbase code that consumed implicit write responses must add
63
+ `.select()` to those calls. Raw REST writes default to `Prefer: return=minimal`;
64
+ use `Prefer: return=representation` to receive rows. Apply the
65
+ `20260924120000_rest_write_capture` server migration before starting the updated
66
+ server: internal trigger capture keeps realtime/webhooks transactional without
67
+ requiring client read access. It does not enable event delivery for arbitrary
68
+ SQL-editor or RPC writes. Motherbase's `transaction()` extension retains its
69
+ existing batch response contract.
70
+
71
+ **Invalid keys are refused.** A project key that is unknown or revoked now
72
+ answers `401 UNAUTHORIZED` even when the project does not require an API key.
73
+ Before, it was ignored and the request ran as anonymous. Remove stale keys from
74
+ client configuration rather than relying on that fallback.
75
+
76
+ Typed, isomorphic (browser, Next.js server & client, Expo / React Native), built
77
+ on global `fetch` with zero runtime dependencies (`socket.io-client` is an
78
+ optional peer dependency, used only for realtime).
79
+
80
+ ## Install
81
+
82
+ ```bash
83
+ npm i @motherbase/client
84
+ # optional, only if you use realtime:
85
+ npm i socket.io-client
86
+ ```
87
+
88
+ ## Quickstart
89
+
90
+ ```ts
91
+ import { createClient, localStorageStorage } from '@motherbase/client';
92
+
93
+ const client = createClient(
94
+ 'http://localhost:5001',
95
+ 'your-anon-key', // optional; sent as an `apikey` header
96
+ { storage: localStorageStorage() } // omit for in-memory (SSR-safe) sessions
97
+ );
98
+
99
+ await client.hydrate(); // restore a persisted session (optional)
100
+
101
+ // Auth
102
+ await client.auth.signUp({ email: 'me@example.com', password: 'secret123' });
103
+ const { data, error } = await client.auth.signInWithPassword({
104
+ email: 'me@example.com',
105
+ password: 'secret123',
106
+ });
107
+
108
+ // Query builder (awaitable, PostgREST-style)
109
+ const { data: products } = await client
110
+ .from('products')
111
+ .select('id,name,price')
112
+ .lt('price', 50)
113
+ .order('name', { ascending: false })
114
+ .limit(20);
115
+
116
+ // Storage. `uploaded.path` is the key you passed, so later download/remove/
117
+ // signed-URL calls take the same string.
118
+ const { data: uploaded } = await client.storage.from('avatars').upload(`${userId}/me.png`, myBlob, {
119
+ visibility: 'public',
120
+ });
121
+
122
+ // Realtime: postgres_changes, broadcast, presence over one channel (Supabase-shaped)
123
+ const room = client
124
+ .channel('room-1', { config: { broadcast: { self: true }, presence: { key: userId } } })
125
+ .on('postgres_changes', { event: '*', schema: 'public', table: 'messages', filter: 'room_id=eq.42' }, p => {
126
+ console.log(p.eventType, p.new, p.old);
127
+ })
128
+ .on('broadcast', { event: 'cursor' }, ({ payload }) => console.log(payload))
129
+ .on('presence', { event: 'sync' }, () => console.log(room.presenceState()))
130
+ .subscribe();
131
+
132
+ await room.send({ type: 'broadcast', event: 'cursor', payload: { x: 1, y: 2 } });
133
+ await room.track({ online_at: Date.now() });
134
+
135
+ // Edge functions
136
+ const { data: result } = await client.functions.invoke('hello', { body: { name: 'Ada' } });
137
+ ```
138
+
139
+ Every data/auth/storage call resolves to `{ data, error }` and **never throws**
140
+ for expected API errors. `error` is `null` on success, otherwise
141
+ `{ message, status?, code? }`.
142
+
143
+ The SDK attaches the access token automatically and revalidates it on a 401
144
+ (single-flight: concurrent requests share one revalidation). Sessions are Better
145
+ Auth bearer sessions, so there is no separate refresh token to rotate: when the
146
+ token is no longer valid the SDK clears the session and emits `SIGNED_OUT`.
147
+
148
+ ## API surface (cheatsheet)
149
+
150
+ ```ts
151
+ // Data. Filters: eq neq gt gte lt lte like ilike likeAllOf likeAnyOf ilikeAllOf
152
+ // ilikeAnyOf in is not or filter match textSearch contains containedBy overlaps
153
+ // rangeGt rangeGte rangeLt rangeLte rangeAdjacent
154
+ client.from(table).select(cols).eq(col, v).in(col, [..]).order(col, { ascending, nullsFirst, referencedTable })
155
+ client.from(table).select(cols).limit(n, { referencedTable }).range(from, to).abortSignal(signal)
156
+ client.from(table).select(cols).or('a.eq.1,and(b.gt.2,c.is.null)') // nests, as PostgREST does
157
+ client.from(table).select(cols).not(col, 'eq', v).filter(col, 'isdistinct', v)
158
+ client.from(table).select(cols).textSearch(col, q, { type: 'websearch', config: 'english' })
159
+ client.from(table).select(cols).single() | .maybeSingle() | .returns<T>() | .csv() | .geojson() | .explain()
160
+ client.from(table).update(v).eq(..).throwOnError() | .maxAffected(1) | .rollback()
161
+ client.from(table).insert(row) | .insert([rowA, rowB], { defaultToNull: false })
162
+ client.from(table).upsert(rows, { onConflict, ignoreDuplicates })
163
+ client.from(table).update(values).eq(..) // a filter is required
164
+ client.from(table).delete().eq(..) // a filter is required
165
+ // Every call resolves { data, error, count }. `count` is null unless you ask:
166
+ // .select('*', { count: 'exact', head: true }).
167
+ // Values are quoted for you: .in('name', ['a,b']) is one value, not two.
168
+ // Writes return { data: null, error: null } by default, like supabase-js.
169
+ // Add .select() to insert/upsert/update/delete to request the written rows.
170
+ // Returning rows requires a SELECT policy as well as the write policy.
171
+ // An unbounded select is capped by api.rest.maxRows (1000 by default): paginate with .range().
172
+ // explain() needs an admin caller; a normal caller gets 403.
173
+
174
+ // Relational embedding. Compiles to a correlated LATERAL subquery inside the
175
+ // same RLS transaction, so embedded rows are policy-filtered too.
176
+ client.from('posts').select('id, author(name), comments(id)') // object / array by FK shape
177
+ client.from('posts').select('id, author!inner(name)') // inner join semantics
178
+ client.from('posts').select('id, comments(count)') // aggregate
179
+
180
+ // Database functions (typed from the generated Database["public"]["Functions"]).
181
+ // Runs in the caller's RLS transaction. SECURITY DEFINER functions are refused
182
+ // unless an operator allowlists them.
183
+ client.rpc(fn, args, { get, head, count })
184
+
185
+ // Auth → { data, error }
186
+ client.auth.signUp({ email, password, username? })
187
+ client.auth.signInWithPassword({ email, password })
188
+ client.auth.signInWithOtp({ email }) // emails a code
189
+ client.auth.verifyOtp({ email, token }) // completes the OTP sign-in
190
+ client.auth.signInWithOAuth({ provider, options: { redirectTo, scopes, skipBrowserRedirect } })
191
+ client.auth.getSessionFromUrl() // finishes the OAuth redirect, see below
192
+ client.auth.resetPasswordForEmail(email)
193
+ client.auth.resetPassword({ email, otp, password })
194
+ client.auth.updateUser({ data, password, currentPassword })
195
+ client.auth.setSession({ access_token })
196
+ client.auth.signOut()
197
+ client.auth.getUser() // { data: { user } }, revalidates against the server
198
+ client.auth.getSession() // { data: { session } }, local only
199
+ client.auth.refreshSession()
200
+ client.auth.onAuthStateChange((event, session) => {}) // SIGNED_IN | SIGNED_OUT | TOKEN_REFRESHED
201
+ client.auth.deleteUser({ password }) // self-service, needs the backend flow enabled
202
+ client.auth.signInWithSSO({ domain | providerId | email })
203
+ client.auth.signInWithIdToken({ provider, token, nonce }) // Apple / Google One Tap
204
+ client.auth.getUserIdentities() | .linkIdentity({ provider }) | .unlinkIdentity({ provider, id })
205
+ client.auth.getClaims() // { claims, token }, decoded from a minted JWT
206
+ client.auth.startAutoRefresh(ms) | .stopAutoRefresh()
207
+ client.auth.mfa.enroll({ password }) | .challenge({ channel }) | .verify({ code, type? })
208
+ client.auth.mfa.listFactors() | .regenerateBackupCodes({ password }) | .unenroll({ password })
209
+ client.auth.admin.* // secret key only: list/get/create/update/delete/signOut/invite
210
+
211
+ // Beyond supabase-js. Method names and parameters are Better Auth's own, so its
212
+ // plugin docs apply; each needs its plugin on in the project's Auth settings.
213
+ client.auth.signInWithPasskey() // browser passkey prompt, then a session like any sign-in
214
+ client.auth.passkey.register({ name }) | .list() | .update({ id, name }) | .delete({ id })
215
+ client.auth.organization.create({ name, slug }) | .list() | .setActive({ organizationId })
216
+ client.auth.organization.getFullOrganization() | .update({ data }) | .delete({ organizationId }) | .leave(...)
217
+ client.auth.organization.inviteMember({ email, role }) | .acceptInvitation({ invitationId }) | .listUserInvitations()
218
+ client.auth.organization.listMembers() | .updateMemberRole({ memberId, role }) | .removeMember({ memberIdOrEmail })
219
+ client.auth.organization.hasPermission({ permissions: { member: ['create'] } })
220
+ client.auth.organization.createTeam({ name }) | .listTeams() | .addTeamMember({ teamId, userId }) // teams on
221
+ client.auth.sso.register({ providerId, issuer, domain, organizationId, oidcConfig | samlConfig })
222
+ client.auth.sso.listProviders() | .getProvider({ providerId }) | .updateProvider(...) | .deleteProvider(...)
223
+
224
+ // Storage → { data, error }. `path` is the object key inside the bucket, and it
225
+ // round trips. The file id returned by upload still works anywhere a path does.
226
+ client.storage.from(bucket).upload(path, file, { visibility, upsert })
227
+ client.storage.from(bucket).download(path, { transform }) // transform: see below
228
+ client.storage.from(bucket).remove([path])
229
+ client.storage.from(bucket).list(prefix, { limit, offset, search, sortBy })
230
+ client.storage.from(bucket).createSignedUrl(path, expiresIn, { transform }) // seconds, 1..604800
231
+ client.storage.from(bucket).createSignedUrls([path], expiresIn) // per-entry results
232
+ client.storage.from(bucket).getPublicUrl(path, { transform }) // sync
233
+ client.storage.from(bucket).update(path, file) // upload with upsert on
234
+ client.storage.from(bucket).copy(from, to, { upsert }) | .move(from, to, { upsert })
235
+ client.storage.from(bucket).info(path) | .exists(path) // 404 reads as false
236
+ client.storage.from(bucket).createSignedUploadUrl(path, { size, contentType }) // S3 driver
237
+ client.storage.from(bucket).uploadToSignedUrl(path, token, file)
238
+ client.storage.createBucket(id, { public, fileSizeLimit, allowedMimeTypes }) // secret key
239
+ // transform: { width, height, resize: 'cover'|'contain'|'fill', quality, format: 'origin'|'webp'|'avif' }
240
+
241
+ // Realtime
242
+ client.channel(name, { config: { broadcast: { self, ack }, presence: { key } } })
243
+ .on('postgres_changes', { event, schema, table, filter }, cb)
244
+ .on('broadcast', { event }, cb)
245
+ .on('presence', { event: 'sync' | 'join' | 'leave' }, cb)
246
+ .subscribe(status => {}) // SUBSCRIBED | TIMED_OUT | CLOSED | CHANNEL_ERROR
247
+ channel.send({ type: 'broadcast', event, payload })
248
+ channel.track(state); channel.untrack(); channel.presenceState()
249
+ client.removeChannel(channel)
250
+
251
+ // Edge functions → { data, error }. `error` is a FunctionsHttpError /
252
+ // FunctionsFetchError instance, but invoke still resolves rather than throws.
253
+ client.functions.invoke(name, { body, method, headers, auth })
254
+ ```
255
+
256
+ ## Resumable uploads
257
+
258
+ Large files go over TUS, exactly as on Supabase: the SDK does not bundle a TUS
259
+ client (supabase-js does not either), so use
260
+ [`tus-js-client`](https://github.com/tus/tus-js-client) against
261
+ `/storage/v1/upload/resumable`. Chunks must be 6 MB. Bucket limits, the key
262
+ collision check and storage policies are enforced when the upload is created,
263
+ and an unfinished upload can be resumed for 24 hours.
264
+
265
+ ```ts
266
+ import { Upload } from 'tus-js-client';
267
+
268
+ const { data: { session } } = await client.auth.getSession();
269
+ const upload = new Upload(file, {
270
+ endpoint: `${MOTHERBASE_URL}/storage/v1/upload/resumable`,
271
+ chunkSize: 6 * 1024 * 1024,
272
+ retryDelays: [0, 3000, 5000, 10000, 20000],
273
+ headers: {
274
+ authorization: `Bearer ${session?.access_token}`,
275
+ apikey: MOTHERBASE_PUBLISHABLE_KEY,
276
+ 'x-upsert': 'true', // optional: replace an existing object
277
+ },
278
+ metadata: { bucketName: 'videos', objectName: 'u1/clip.mp4', contentType: 'video/mp4' },
279
+ onProgress: (sent, total) => console.log(Math.round((sent / total) * 100), '%'),
280
+ onSuccess: () => console.log('uploaded'),
281
+ });
282
+ upload.findPreviousUploads().then(previous => {
283
+ if (previous[0]) upload.resumeFromPreviousUpload(previous[0]);
284
+ upload.start();
285
+ });
286
+ ```
287
+
288
+ ## Social sign-in (OAuth)
289
+
290
+ A provider callback is a browser redirect, so it cannot answer the SDK with a
291
+ session the way a password sign-in does. Two calls, not one:
292
+
293
+ ```ts
294
+ // 1. Wherever the button lives. Navigates the browser to the backend, which
295
+ // redirects on to the provider.
296
+ await client.auth.signInWithOAuth({
297
+ provider: 'google',
298
+ options: { redirectTo: 'https://app.example.com/auth/callback' },
299
+ });
300
+
301
+ // 2. On that page (or on app start). Reads the token the backend appended to
302
+ // the redirect, stores the session, and strips it from the address bar.
303
+ const { data, error } = await client.auth.getSessionFromUrl();
304
+ ```
305
+
306
+ `getSessionFromUrl()` resolves with a null session and no error when there is
307
+ nothing to read, so calling it on every page load is safe.
308
+
309
+ Step 1 is a full-page navigation, not a fetch. That is deliberate: the backend
310
+ sets an OAuth state cookie there and re-checks it on the provider callback, and
311
+ a cookie set on a cross-site fetch response is refused by Safari and Firefox and
312
+ by Chrome in incognito. Navigating makes it a first-party cookie, so the flow
313
+ works in every browser even when your app and your API are on different domains.
314
+ If you are not using the SDK, point the button at the same URL:
315
+
316
+ ```
317
+ https://<your-api>/api/auth/sign-in/social/google?callbackURL=https://app.example.com/auth/callback
318
+ ```
319
+
320
+ `callbackURL` is validated against the trusted origins, so it has to be an
321
+ origin you allowlisted.
322
+
323
+ Two backend settings decide whether this works at all:
324
+
325
+ - **Callback URL.** Studio, Authentication, Sign In / Providers shows the exact
326
+ URL to register in the provider's console.
327
+ - **CORS allowed origins.** Studio, Authentication settings, Security · CORS.
328
+ If your app is served from a different origin than the API, that origin must
329
+ be listed. Otherwise the browser refuses the cookie the sign-in start sets,
330
+ and the provider callback comes back as `?error=state_mismatch`.
331
+
332
+ ## Storage adapters
333
+
334
+ | Runtime | Adapter |
335
+ | -------- | ------------------------------------------------------------- |
336
+ | Browser | `localStorageStorage()` or `cookieStorage()` |
337
+ | SSR/Node | `memoryStorage()` (default), `serverCookieStorage(cookieMethods)` (getAll/setAll, see `@motherbase/ssr` for Next.js helpers), or a custom `{ get, set }` adapter |
338
+ | Expo/RN | custom `{ get, set }` backed by `AsyncStorage` |
339
+
340
+ ```ts
341
+ import AsyncStorage from '@react-native-async-storage/async-storage';
342
+ import { createClient } from '@motherbase/client';
343
+
344
+ const client = createClient(API_URL, ANON_KEY, {
345
+ storage: {
346
+ async get() {
347
+ const raw = await AsyncStorage.getItem('session');
348
+ return raw ? JSON.parse(raw) : null;
349
+ },
350
+ async set(session) {
351
+ if (session) await AsyncStorage.setItem('session', JSON.stringify(session));
352
+ else await AsyncStorage.removeItem('session');
353
+ },
354
+ },
355
+ });
356
+ ```
357
+
358
+ ## Compatibility matrix
359
+
360
+ The matrix is **not maintained by hand**. It lives in
361
+ [`COMPATIBILITY.md`](./COMPATIBILITY.md) and is generated by
362
+ `apps/server/tests/sdk-contract.integration.test.ts`, which runs every listed
363
+ supabase-js call against a live Motherbase server and a live Postgres. Each row
364
+ carries the id of the test that produced it.
365
+
366
+ Rows marked `Not supported` assert the current failure behaviour, so the day a
367
+ feature lands its test fails and the document has to be regenerated. That is the
368
+ mechanism that keeps this page from over-claiming (or under-claiming) again. CI
369
+ regenerates the file and fails if the committed copy differs.
370
+
371
+ Regenerate it locally with:
372
+
373
+ ```bash
374
+ npm run test -w apps/server -- sdk-contract
375
+ ```
376
+
377
+ More examples live in [`docs/sdk.md`](../../docs/sdk.md). Project Studio also
378
+ exposes a project-specific Connect page with the exact API URL, WebSocket URL and
379
+ project ID to paste into `.env`.