@weirdscience/based-client 0.4.1 → 0.6.1

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,8 +1,8 @@
1
1
  # @weirdscience/based-client
2
2
 
3
- React SDK for [Based](https://based.weirdscience.dev) — a minimal self-hosted Backend-as-a-Service.
3
+ SDK for [Based](https://based.weirdscience.dev) — a minimal self-hosted Backend-as-a-Service.
4
4
 
5
- Hooks for auth, queries, and mutations. Type-safe end-to-end when paired with `based typegen`.
5
+ A typed data builder and auth helpers for any JavaScript app, plus React hooks for queries and mutations. Type-safe end-to-end when paired with `based typegen`.
6
6
 
7
7
  ## Install
8
8
 
@@ -12,10 +12,55 @@ bun add @weirdscience/based-client
12
12
  # or: pnpm add @weirdscience/based-client
13
13
  ```
14
14
 
15
- Peer dependency: `react >=18`.
15
+ React (`>=18`) is an optional peer dependency: only the root entry needs it. Projects without React import from `@weirdscience/based-client/core` (next section).
16
+
17
+ ## Without React — `@weirdscience/based-client/core`
18
+
19
+ The `core` entry point is the whole client minus the provider and hooks: `createClient`, `from()`, `auth`, `BasedError` and the types. It has no dependency on React and no `"use client"` directive, so it works in Vue, Svelte, Node, Bun, Deno, workers, or plain scripts.
20
+
21
+ <!-- @typecheck -->
22
+ ```ts
23
+ import { createClient, BasedError } from "@weirdscience/based-client/core";
24
+ import type { Tables } from "./based";
25
+
26
+ const based = createClient<Tables>({
27
+ url: "https://my-app.based.example.com",
28
+ anonKey: "public-anon-key",
29
+ storage: false, // no localStorage outside the browser
30
+ });
31
+
32
+ await based.auth.signIn("you@example.com", "password123");
33
+
34
+ const { data: posts } = await based.from("posts").select({ order: "createdAt.desc", limit: 10 });
35
+
36
+ try {
37
+ await based.from("posts").insert({ title: "Hello" });
38
+ } catch (err) {
39
+ if (err instanceof BasedError && err.status === 403) {
40
+ // not allowed by the table's access policy
41
+ }
42
+ }
43
+ ```
44
+
45
+ Everything in the [Data](#data--clientfromtable), [Auth](#auth), [Session persistence](#session-persistence) and [Type safety](#type-safety) sections applies to `core` unchanged. Only the [Hooks](#hooks) need React.
46
+
47
+ ## Breaking changes in 0.5.0
48
+
49
+ - `mutate()` resolves to `Row | null` and no longer throws — check the returned
50
+ row, or the hook's `error`, instead of wrapping the call in `try`/`catch`.
51
+ - A hook's `error` is a `HookError` object (`{ code, message, status?, details? }`),
52
+ not an `Error`: read `error.message`, and branch on `error.code`.
53
+ - `AuthUser` has a `role` (`"owner" | "member"`).
54
+ - `signIn` / `signUp` throw a `BasedError` instead of resolving with an error.
16
55
 
17
56
  ## Quick start
18
57
 
58
+ Both bundles (ESM and CJS) ship with the `"use client"` directive, so
59
+ `BasedProvider` can be rendered straight from `app/layout.tsx` — no wrapper
60
+ component needed. The hooks are still client-side code: the file that *calls* a
61
+ hook needs its own `"use client"`, and a Server Component cannot call one.
62
+
63
+ <!-- @typecheck -->
19
64
  ```tsx
20
65
  import { createClient, BasedProvider } from "@weirdscience/based-client";
21
66
 
@@ -29,12 +74,70 @@ export default function App({ children }: { children: React.ReactNode }) {
29
74
  }
30
75
  ```
31
76
 
77
+ ## Data — `client.from(table)`
78
+
79
+ The imperative counterpart to the hooks, for event handlers, server scripts, and
80
+ anything outside React. Every method throws a `BasedError` on failure.
81
+
82
+ <!-- @typecheck -->
83
+ ```ts
84
+ import { createClient } from "@weirdscience/based-client";
85
+ import type { Tables } from "./based";
86
+
87
+ const based = createClient<Tables>({ url, anonKey });
88
+ const posts = based.from("posts");
89
+
90
+ // List rows: filter, order, limit (default 20, max 100), offset
91
+ const { data, total } = await posts.select({
92
+ filter: { status: "published" },
93
+ order: "createdAt.desc",
94
+ limit: 20,
95
+ offset: 0,
96
+ });
97
+
98
+ // One row by id — resolves to null on 404 instead of throwing
99
+ const post = await posts.get("V1StGXR8_Z5jdHi6B");
100
+
101
+ // Create. Omit id and the server generates a nanoid
102
+ const created = await posts.insert({ title: "Hello" });
103
+
104
+ // Upsert at an id you choose: creates if missing, updates if present
105
+ await based.from("preferences").upsert({ id: "alice--theme", value: "dark" });
106
+
107
+ // Same request, id passed separately
108
+ await posts.update("V1StGXR8_Z5jdHi6B", { title: "Renamed" });
109
+
110
+ // Delete
111
+ await posts.delete("V1StGXR8_Z5jdHi6B");
112
+ ```
113
+
114
+ `select` returns `{ data, total }` where `total` counts the matching rows before
115
+ pagination. `order` is `"<column>.asc"` or `"<column>.desc"`, checked against the
116
+ row type; omit it and the server sorts by `id` ascending so pagination is stable.
117
+
118
+ ### `client.fetch`
119
+
120
+ The request everything else is built on: it prefixes the project URL, attaches
121
+ the anon key or the bearer token, and retries once after refreshing an expired
122
+ access token. Use it for anything the builder does not cover.
123
+
124
+ <!-- @typecheck -->
125
+ ```ts
126
+ import { createClient } from "@weirdscience/based-client";
127
+
128
+ const based = createClient({ url, anonKey });
129
+
130
+ const res = await based.fetch("/api/posts?limit=5&order=createdAt.desc");
131
+ const body = (await res.json()) as { data: unknown[]; total: number };
132
+ ```
133
+
32
134
  ## Hooks
33
135
 
34
136
  ### `useUser()`
35
137
 
36
138
  Current authenticated user.
37
139
 
140
+ <!-- @typecheck -->
38
141
  ```tsx
39
142
  import { useUser } from "@weirdscience/based-client";
40
143
 
@@ -42,36 +145,64 @@ function Profile() {
42
145
  const { user, isLoading } = useUser();
43
146
  if (isLoading) return <p>...</p>;
44
147
  if (!user) return <p>Not logged in</p>;
45
- return <p>{user.email}</p>;
148
+ return <p>{user.email} ({user.role})</p>;
46
149
  }
47
150
  ```
48
151
 
152
+ `user` is `{ id, email, role }`, where `role` is `"owner"` for the first account
153
+ created on the project and `"member"` for everyone after. Only an owner can
154
+ manage schema. `error` is part of the return shape for symmetry with the other
155
+ hooks, and is always `null` today — the hook reads client state, it never fetches.
156
+
49
157
  ### `useQuery(table, options?)`
50
158
 
51
159
  Read rows from a table. Returns `{ data, total, isLoading, error, refetch }`.
52
160
 
161
+ <!-- @typecheck -->
53
162
  ```tsx
54
163
  import { useQuery } from "@weirdscience/based-client";
55
164
 
56
165
  const { data, total, isLoading } = useQuery("posts", {
57
166
  filter: { status: "published" },
167
+ order: "createdAt.desc",
58
168
  limit: 20,
59
169
  offset: 0,
60
170
  });
61
171
  ```
62
172
 
173
+ `limit` defaults to 20 and the server caps it at 100 — a larger value is clamped,
174
+ not rejected. `order` is `"<column>.asc"` or `"<column>.desc"`, and the column is
175
+ checked against the table's type. Omit it and rows come back ordered by `id`
176
+ ascending, so pagination stays stable.
177
+
178
+ `error` is a `HookError` — `{ code, message, status?, details? }`, the server's
179
+ error code and HTTP status, never an `Error` instance.
180
+
63
181
  Gate the query behind auth or any boolean with `enabled`:
64
182
 
183
+ <!-- @typecheck -->
65
184
  ```tsx
66
- const { user } = useUser();
67
- const { data } = useQuery("notes", { enabled: !!user });
68
- // Won't fire until `user` is truthy — avoids a 403 when signed out.
185
+ import { useQuery, useUser } from "@weirdscience/based-client";
186
+
187
+ function Notes() {
188
+ const { user } = useUser();
189
+ const { data, isLoading } = useQuery("notes", { enabled: !!user });
190
+ // Won't fire until `user` is truthy — avoids a 403 when signed out.
191
+ // While disabled: `data` is null and `isLoading` is false.
192
+ return <p>{isLoading ? "..." : `${data?.length ?? 0} notes`}</p>;
193
+ }
69
194
  ```
70
195
 
196
+ Queries refetch on their own when the signed-in user changes, so signing out
197
+ clears the previous user's rows instead of leaving them on screen. Responses
198
+ that arrive out of order are discarded — only the newest request can write to
199
+ `data`.
200
+
71
201
  ### `useRecord(table, id, options?)`
72
202
 
73
203
  Fetch a single record by id via `GET /api/:table/:id`. Returns `{ data, isLoading, error, refetch }` — `data` is a single row, not an array.
74
204
 
205
+ <!-- @typecheck -->
75
206
  ```tsx
76
207
  import { useRecord } from "@weirdscience/based-client";
77
208
 
@@ -79,7 +210,7 @@ function Post({ id }: { id: string }) {
79
210
  const { data: post, isLoading } = useRecord("posts", id);
80
211
  if (isLoading) return <Spinner />;
81
212
  if (!post) return <NotFound />;
82
- return <h1>{post.title}</h1>;
213
+ return <h1>{String(post.title)}</h1>;
83
214
  }
84
215
  ```
85
216
 
@@ -97,36 +228,64 @@ const { data: prefs } = useRecord("preferences", `${user.id}:theme`);
97
228
 
98
229
  ### `useMutation(table, operation)`
99
230
 
100
- Write rows. `operation` is `"create" | "update" | "delete"`. Returns `{ mutate, isLoading, error }`.
231
+ Write rows. `operation` is `"create" | "update" | "upsert" | "delete"`. Returns `{ mutate, isLoading, error }`.
232
+
233
+ `mutate` resolves to the row on success and to `null` on failure — it never
234
+ throws, so `onClick={() => mutate(...)}` can't produce an unhandled rejection.
235
+ Check the returned value or `error` (`{ code, message, status?, details? }`)
236
+ instead of reaching for `try/catch`.
101
237
 
238
+ | Operation | `mutate(data)` | Request |
239
+ | --- | --- | --- |
240
+ | `create` | `{ ...fields }` | `POST /api/:table` — 201, unknown keys rejected |
241
+ | `update` | `{ id, ...fields }` | `PUT /api/:table/:id` — upsert: 201 on create, 200 on update |
242
+ | `upsert` | `{ id, ...fields }` | Same request as `update`; reads better when you pick the id |
243
+ | `delete` | `{ id }` | `DELETE /api/:table/:id` |
244
+
245
+ <!-- @typecheck -->
102
246
  ```tsx
103
247
  import { useMutation } from "@weirdscience/based-client";
104
248
 
105
249
  function NewPost() {
106
- const { mutate, isLoading } = useMutation("posts", "create");
250
+ const { mutate, isLoading, error } = useMutation("posts", "create");
251
+
252
+ async function onCreate() {
253
+ const created = await mutate({ title: "Hello", content: "World" });
254
+ if (!created) return; // `error` holds { code, message, status?, details? }
255
+ }
256
+
107
257
  return (
108
- <button
109
- onClick={() => mutate({ title: "Hello", content: "World" })}
110
- disabled={isLoading}
111
- >
112
- Create
113
- </button>
258
+ <>
259
+ <button onClick={onCreate} disabled={isLoading}>Create</button>
260
+ {error && <p>{error.message}</p>}
261
+ </>
114
262
  );
115
263
  }
116
264
  ```
117
265
 
118
- `update` and `delete` require an `id` field:
266
+ `update`, `upsert`, and `delete` all require an `id` field:
119
267
 
268
+ <!-- @typecheck -->
120
269
  ```tsx
121
- const { mutate: update } = useMutation("posts", "update");
122
- await update({ id: "abc", title: "Renamed" });
270
+ import { useMutation } from "@weirdscience/based-client";
123
271
 
124
- const { mutate: remove } = useMutation("posts", "delete");
125
- await remove({ id: "abc" });
272
+ function PostActions({ id }: { id: string }) {
273
+ const { mutate: update, error } = useMutation("posts", "update");
274
+ const { mutate: remove } = useMutation("posts", "delete");
275
+
276
+ return (
277
+ <>
278
+ <button onClick={() => update({ id, title: "Renamed" })}>Rename</button>
279
+ <button onClick={() => remove({ id })}>Delete</button>
280
+ {error && <p>{error.message}</p>}
281
+ </>
282
+ );
283
+ }
126
284
  ```
127
285
 
128
286
  ## Auth
129
287
 
288
+ <!-- @typecheck -->
130
289
  ```tsx
131
290
  import { useBasedClient } from "@weirdscience/based-client";
132
291
 
@@ -156,27 +315,77 @@ Methods on `client.auth`:
156
315
 
157
316
  - `signUp(email, password)` → creates an account and signs in
158
317
  - `signIn(email, password)` → signs in
159
- - `signOut()` → invalidates the session
318
+ - `signOut()` → deletes this account's sessions on the server
160
319
  - `refreshSession()` → manually refresh (happens automatically on 401)
320
+ - `getUser()` → the current user from `/auth/me`
321
+ - `onAuthStateChange(cb)` → calls `cb(state)` on every auth change, returns an unsubscribe function
322
+
323
+ `signUp` and `signIn` throw a `BasedError` carrying the server's `code`, `message`, HTTP `status`, and optional `details`:
324
+
325
+ <!-- @typecheck -->
326
+ ```tsx
327
+ import { BasedError, createClient } from "@weirdscience/based-client";
328
+
329
+ const client = createClient({ url, anonKey });
330
+
331
+ async function signIn(email: string, password: string) {
332
+ try {
333
+ await client.auth.signIn(email, password);
334
+ } catch (err) {
335
+ if (err instanceof BasedError && err.status === 401) {
336
+ return "Wrong email or password";
337
+ }
338
+ throw err;
339
+ }
340
+ }
341
+ ```
342
+
343
+ Subscribe to every auth change — sign in, sign out, refresh, rehydration:
344
+
345
+ <!-- @typecheck -->
346
+ ```ts
347
+ import { createClient } from "@weirdscience/based-client";
161
348
 
162
- Access tokens auto-refresh on `401`.
349
+ const client = createClient({ url, anonKey });
350
+
351
+ const unsubscribe = client.auth.onAuthStateChange((state) => {
352
+ console.log(state.user?.email ?? "signed out", state.isLoading);
353
+ });
354
+ ```
355
+
356
+ Access tokens auto-refresh on `401`. Concurrent requests that all see a `401` share a single refresh — the server rotates the refresh token, so parallel refreshes would sign the user out.
163
357
 
164
358
  ## Session persistence
165
359
 
166
- Sessions persist across page reloads via `localStorage` by default. On mount, the client restores the saved session and validates it by calling `/auth/me`.
360
+ Sessions persist across page reloads via `localStorage` by default, under the key
361
+ `based.session`. On mount, the client restores the saved session and validates it
362
+ by calling `/auth/me`.
363
+
364
+ Tokens in `localStorage` are readable by any script running on your origin, so an
365
+ XSS bug leaks the session. That is the deliberate tradeoff for a session that
366
+ survives a reload without a cookie backend — pass your own `storage` adapter (or
367
+ `storage: false`) if your threat model needs something stricter.
167
368
 
168
369
  Use `client.ready()` or `useUser().isLoading` to avoid flashing a logged-out UI during hydration:
169
370
 
371
+ <!-- @typecheck -->
170
372
  ```tsx
171
- const { user, isLoading } = useUser();
172
- if (isLoading) return <Spinner />;
173
- if (!user) return <LoginForm />;
174
- return <Dashboard user={user} />;
373
+ import { useUser } from "@weirdscience/based-client";
374
+
375
+ function Gate() {
376
+ const { user, isLoading } = useUser();
377
+ if (isLoading) return <Spinner />;
378
+ if (!user) return <LoginForm />;
379
+ return <Dashboard user={user} />;
380
+ }
175
381
  ```
176
382
 
177
- Opt out or use a custom storage adapter:
383
+ Opt out, pick your own key, or use a custom storage adapter:
384
+
385
+ <!-- @typecheck -->
386
+ ```ts
387
+ import { createClient } from "@weirdscience/based-client";
178
388
 
179
- ```tsx
180
389
  // Disable entirely (in-memory only)
181
390
  createClient({ url, anonKey, storage: false });
182
391
 
@@ -184,6 +393,7 @@ createClient({ url, anonKey, storage: false });
184
393
  createClient({
185
394
  url,
186
395
  anonKey,
396
+ storageKey: "my-app.session",
187
397
  storage: {
188
398
  getItem: (k) => AsyncStorage.getItem(k),
189
399
  setItem: (k, v) => AsyncStorage.setItem(k, v),
@@ -203,47 +413,55 @@ based typegen
203
413
  # writes based.d.ts
204
414
  ```
205
415
 
206
- Pass the generated `Tables` type as a generic:
416
+ Import it as `./based` never `./based.d.ts`, which TypeScript refuses — and
417
+ pass `Tables` as a generic:
207
418
 
419
+ <!-- @typecheck -->
208
420
  ```tsx
209
- import type { Tables } from "./based.d.ts";
421
+ import type { Insert, Tables } from "./based";
210
422
  import { useQuery, useMutation } from "@weirdscience/based-client";
211
423
 
212
- // data is typed as Tables["posts"][]
424
+ // data is typed as Tables["posts"][] | null
213
425
  const { data } = useQuery<Tables, "posts">("posts", {
214
426
  filter: { status: "published" }, // typed keys
427
+ order: "createdAt.desc", // typed columns
215
428
  });
216
429
 
430
+ // mutate() takes an Insert<"posts">: the row minus the columns the server
431
+ // fills in (id, createdAt, updatedAt)
217
432
  const { mutate } = useMutation<Tables, "posts">("posts", "create");
218
- await mutate({ title: "Hello", content: "World" }); // typed payload
433
+ const draft: Insert<"posts"> = {
434
+ title: "Hello",
435
+ content: "World",
436
+ status: "draft",
437
+ };
438
+ await mutate(draft);
219
439
  ```
220
440
 
221
441
  Re-run `based typegen` after any schema change.
222
442
 
223
- ## Row-level isolation
443
+ ## Access policies
444
+
445
+ Each table has one policy, and `based table access <table> <policy>` changes it:
224
446
 
225
- If a table has a `user_id` (or `userId`) column, Based auto-scopes CRUD to the authenticated user. No configuration needed just add the column:
447
+ - **`owner-scoped`** (default when the table has a `user_id` column) — every
448
+ signed-in user reads and writes only their own rows; `user_id` is forced to the
449
+ caller on write; anon is rejected.
450
+ - **`public-read`** — anyone, anon included, reads every row; only the project
451
+ owner writes.
452
+ - **`private`** (default without a `user_id` column) — only the project owner
453
+ reads or writes.
226
454
 
227
455
  ```bash
228
456
  based table create notes user_id:text:required title:text:required body:text
457
+ based table access posts public-read
229
458
  ```
230
459
 
231
- After that:
460
+ With `owner-scoped`:
232
461
 
233
462
  - `useQuery("notes")` only returns the caller's notes
234
463
  - `useMutation("notes", "create")` auto-fills `user_id`
235
- - Other users' rows return 404
236
-
237
- ## Upsert
238
-
239
- `PUT /api/:table/:id` creates if missing, updates if present. From the SDK:
240
-
241
- ```tsx
242
- const { mutate: upsert } = useMutation("preferences", "update");
243
-
244
- // The URL id becomes the row id — perfect for deterministic keys like userId:key
245
- await upsert({ id: "alice:theme", value: "dark" });
246
- ```
464
+ - other users' rows return 404
247
465
 
248
466
  ## Links
249
467
 
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAGlB,WAAW,EAEX,aAAa,EAId,MAAM,SAAS,CAAC;AAsBjB,wBAAgB,YAAY,CAC1B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,aAAa,EACjE,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAAC,CAAC,CAAC,CAkW7C"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAGlB,WAAW,EAEX,aAAa,EAId,MAAM,SAAS,CAAC;AAsBjB,wBAAgB,YAAY,CAC1B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,aAAa,EACjE,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAAC,CAAC,CAAC,CAwY7C"}