@motherbase/client 1.5.2 → 2.0.2

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