@basictech/react 0.8.0-beta.4 → 0.9.0-beta.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,6 +1,32 @@
1
1
  # @basictech/react
2
2
 
3
- React SDK for [Basic](https://basic.tech) - add authentication and real-time database to your React app in minutes.
3
+ React SDK for [Basic](https://basic.tech) authentication and an offline-first, synced database for your React app.
4
+
5
+ Basic gives every user their own personal datastore (a PDS). Your app authenticates with OAuth, gets its own sandboxed slice of the user's datastore, and this SDK keeps a local replica of that data in the browser — reads are instant and local, writes work offline, and everything syncs live across the user's devices over the Basic **Sync/2** protocol.
6
+
7
+ > **Breaking change (0.9):** this version is a ground-up rewrite for the new Basic auth system and the Sync/2 sync engine. The 0.8.x sync client speaks a protocol the server no longer supports. See [Migrating from 0.8](#migrating-from-08).
8
+
9
+ ---
10
+
11
+ ## Contents
12
+
13
+ - [Installation](#installation)
14
+ - [Quick start](#quick-start)
15
+ - [How it works](#how-it-works)
16
+ - [Anonymous & local-first](#anonymous--local-first)
17
+ - [Multiple users](#multiple-users)
18
+ - [Authentication](#authentication)
19
+ - [Database](#database)
20
+ - [Live queries](#live-queries)
21
+ - [Sync status, offline behavior & errors](#sync-status-offline-behavior--errors)
22
+ - [Shares (multiplayer)](#shares-multiplayer)
23
+ - [Typed schemas](#typed-schemas)
24
+ - [REST mode](#rest-mode)
25
+ - [API reference](#api-reference)
26
+ - [Advanced usage](#advanced-usage)
27
+ - [Migrating from 0.8](#migrating-from-08)
28
+
29
+ ---
4
30
 
5
31
  ## Installation
6
32
 
@@ -8,13 +34,16 @@ React SDK for [Basic](https://basic.tech) - add authentication and real-time dat
8
34
  npm install @basictech/react
9
35
  ```
10
36
 
11
- ## Quick Start
37
+ Requires React 16.8+ (hooks). React 17, 18, and 19 are supported.
38
+
39
+ ## Quick start
12
40
 
13
- ### 1. Create a Schema
41
+ ### 1. Define your schema
14
42
 
15
- Create a `basic.config.ts` file with your project configuration:
43
+ Create a `basic.config.ts` with your project id (from [app.basic.tech](https://app.basic.tech)) and your tables:
16
44
 
17
45
  ```typescript
46
+ // basic.config.ts
18
47
  export const schema = {
19
48
  project_id: "YOUR_PROJECT_ID",
20
49
  version: 1,
@@ -22,7 +51,7 @@ export const schema = {
22
51
  todos: {
23
52
  type: "collection",
24
53
  fields: {
25
- title: { type: "string", indexed: true },
54
+ title: { type: "string", indexed: true, required: true },
26
55
  completed: { type: "boolean", indexed: true },
27
56
  },
28
57
  },
@@ -30,319 +59,407 @@ export const schema = {
30
59
  };
31
60
  ```
32
61
 
33
- ### 2. Add the Provider
62
+ Field types: `string`, `number`, `boolean`, `json`. Publish the schema from the Basic dashboard — sync is enabled once the published version matches your local one.
34
63
 
35
- Wrap your app with `BasicProvider`:
64
+ ### 2. Wrap your app
36
65
 
37
66
  ```tsx
67
+ // main.tsx
38
68
  import { BasicProvider } from "@basictech/react";
39
69
  import { schema } from "./basic.config";
40
70
 
41
- function App() {
42
- return (
43
- <BasicProvider schema={schema}>
44
- <YourApp />
45
- </BasicProvider>
46
- );
47
- }
71
+ createRoot(document.getElementById("root")!).render(
72
+ <BasicProvider schema={schema} devToolbar>
73
+ <App />
74
+ </BasicProvider>,
75
+ );
48
76
  ```
49
77
 
50
- ### 3. Use the Hook
51
-
52
- Access auth and database in any component:
78
+ ### 3. Sign in and use the database
53
79
 
54
80
  ```tsx
55
81
  import { useBasic, useQuery } from "@basictech/react";
56
82
 
57
- function TodoList() {
58
- const { db, isSignedIn, signIn, signOut, user } = useBasic();
59
-
60
- // Live query - automatically updates when data changes
61
- const todos = useQuery(() => db.collection("todos").getAll());
62
-
63
- const addTodo = async () => {
64
- await db.collection("todos").add({
65
- title: "New todo",
66
- completed: false,
67
- });
68
- };
83
+ function Todos() {
84
+ const { isSignedIn, signIn, signOut, user, db } = useBasic();
85
+ const todos = useQuery(() => db.table("todos").getAll());
69
86
 
70
87
  if (!isSignedIn) {
71
- return <button onClick={signIn}>Sign In</button>;
88
+ return <button onClick={() => signIn()}>Sign in with Basic</button>;
72
89
  }
73
90
 
74
91
  return (
75
92
  <div>
76
- <p>Welcome, {user?.email}</p>
77
- <button onClick={addTodo}>Add Todo</button>
78
- <ul>
79
- {todos?.map((todo) => (
80
- <li key={todo.id}>{todo.title}</li>
81
- ))}
82
- </ul>
83
- <button onClick={signOut}>Sign Out</button>
93
+ <p>hi {user?.email} <button onClick={() => signOut()}>sign out</button></p>
94
+
95
+ <button onClick={() => db.table("todos").create({ title: "hello", completed: false })}>
96
+ add todo
97
+ </button>
98
+
99
+ {todos?.map((todo) => (
100
+ <label key={todo.id}>
101
+ <input
102
+ type="checkbox"
103
+ checked={!!todo.completed}
104
+ onChange={() => db.table("todos").patch(todo.id, { completed: !todo.completed })}
105
+ />
106
+ {String(todo.title)}
107
+ <button onClick={() => db.table("todos").delete(todo.id)}>×</button>
108
+ </label>
109
+ ))}
84
110
  </div>
85
111
  );
86
112
  }
87
113
  ```
88
114
 
89
- ---
115
+ That's the whole loop — and note the app **works before sign-in too**: `db.table(...)` writes go to a local anonymous workspace and merge into the account automatically when the user signs in (see [Anonymous & local-first](#anonymous--local-first)). `useQuery` re-renders whenever local data changes — whether the change came from this tab, another device, or another user via a share.
116
+
117
+ ## How it works
118
+
119
+ In the default `sync` mode, the SDK maintains a **local replica** of your app's slice of the user's datastore, in IndexedDB, and syncs it over one WebSocket:
120
+
121
+ 1. **Local first** — the local database opens immediately, with no session and no network. Reads and writes work from the first render, including offline cold starts.
122
+ 2. **Writes are ops** — every write is a `put` (create/replace), `patch` (shallow field merge), or `delete` operation with a client-minted unique id. Ops apply to the local view immediately (optimistic), queue in IndexedDB, and push to the server when online. Retries are always safe: the server deduplicates by op id.
123
+ 3. **Reads are local** — `get`/`getAll`/`find` read the local view: confirmed server state with your pending writes layered on top. No network round-trip.
124
+ 4. **Bootstrap on connect** — when a session exists, the SDK fetches a snapshot of the account's current state, layers your pending writes on top, then subscribes for live changes.
125
+ 5. **The server is the authority** — it orders all ops (last-writer-wins per record; `patch`es merge per field), validates them against your published schema, and echoes them to every connected device. If it rejects a write, the SDK rolls it back locally and surfaces it (see [rejected ops](#rejected-ops)).
126
+ 6. **Sign-out is clean** — the user's local data is wiped, the session is revoked server-side, and the app continues in a fresh anonymous workspace. No page reloads.
90
127
 
91
- ## API Reference
128
+ ## Anonymous & local-first
92
129
 
93
- ### `<BasicProvider>`
130
+ Anonymous mode is **on by default**: users can use your app without signing in. Their data lives in a local-only workspace (sync status `local`), fully readable and writable, surviving reloads.
94
131
 
95
- Root provider component. Must wrap your entire app.
132
+ When they sign in, the anonymous workspace **becomes** their account: every anonymous write is already a queued op, so the normal bootstrap-and-push flow merges them into the account — no copy step, no data loss, and live queries keep working through the transition.
96
133
 
97
134
  ```tsx
98
- <BasicProvider
99
- schema={schema} // Required: Your Basic schema
100
- debug={false} // Optional: Enable console logging
101
- dbMode="sync" // Optional: "sync" (default) or "remote"
102
- devToolbar={false} // Optional: Floating dev status bar (localhost / dev / debug)
103
- />
104
- ```
135
+ const { isAnonymous, isSignedIn, signIn, db } = useBasic();
105
136
 
106
- #### Props
137
+ // works signed in or not:
138
+ await db.table("todos").create({ title: "works offline & anonymous" });
107
139
 
108
- | Prop | Type | Default | Description |
109
- | ------------ | -------------------- | -------- | --------------------------------------------------------------------------------------------------- |
110
- | `schema` | `object` | required | Schema with `project_id` and `tables` |
111
- | `debug` | `boolean` | `false` | Enable debug logging |
112
- | `dbMode` | `"sync" \| "remote"` | `"sync"` | Database mode |
113
- | `devToolbar` | `boolean` | `false` | Show the Basic dev toolbar (only when `localhost`, `NODE_ENV === "development"`, or `debug={true}`) |
140
+ // prompt when you want the data to start syncing:
141
+ {isAnonymous && <button onClick={() => signIn()}>Sign in to sync</button>}
142
+ ```
114
143
 
115
- #### Database Modes
144
+ Safety guarantees:
116
145
 
117
- - **`sync`** - Local-first with IndexedDB + real-time sync via WebSocket
118
- - **`remote`** - Direct REST API calls (no local storage)
146
+ - A keyspace is **stamped with the account DID** at first bootstrap. Local data from one account can never merge into another — if a different account signs in over it, the local cache is wiped first (it's server-backed; nothing is lost).
147
+ - Signing out wipes that user's local data and drops into a fresh anonymous workspace (nothing readable is left behind on shared machines).
119
148
 
120
- ---
149
+ Set `anonymous={false}` on the provider to require sign-in before any local data exists (classic behavior).
121
150
 
122
- ### `useBasic()`
151
+ ## Multiple users
123
152
 
124
- Main hook for accessing auth and database.
153
+ Several local users anonymous or signed-in — can exist side by side, one active at a time. The active user is **per tab** (two tabs can be on different users); the profiles themselves are shared across tabs. Each user has an isolated keyspace and session.
125
154
 
126
155
  ```tsx
127
- const {
128
- // Auth state
129
- isReady, // boolean - SDK initialized
130
- isSignedIn, // boolean - Session is valid
131
- user, // { id, email, ... } | null (may be null briefly during profile refresh/recovery)
132
-
133
- // Auth methods
134
- signIn, // () => void - Redirect to login
135
- signOut, // () => void - Clear session
136
- signInWithCode, // (code, state?) => Promise - Manual OAuth
137
- getSignInUrl, // (redirectUri?) => string - Get OAuth URL
138
- getToken, // () => Promise<string> - Get access token
139
-
140
- // Database
141
- db, // Database instance
142
- dbStatus, // DBStatus - see below
143
- dbMode, // "sync" | "remote"
144
-
145
- // Dev / schema snapshot (for custom tooling)
146
- devInfo, // BasicSchemaDevInfo | null — local vs remote schema status
147
- refreshSchemaStatus, // () => Promise<void> — re-fetch schema status from API
148
- } = useBasic();
156
+ const { users, activeUser, switchUser, addUser, removeUser } = useUsers();
157
+
158
+ <select value={activeUser?.id} onChange={(e) => switchUser(e.target.value)}>
159
+ {users.map((u) => (
160
+ <option key={u.id} value={u.id}>
161
+ {u.kind === "anon" ? "Anonymous" : u.email || u.name || u.did}
162
+ </option>
163
+ ))}
164
+ </select>
165
+ <button onClick={() => addUser()}>Add user</button>
149
166
  ```
150
167
 
151
- #### `DBStatus` (sync connection state)
168
+ - `switchUser(id)` swaps the active session, database, and sync connection in place. Live queries via `useQuery` re-attach automatically.
169
+ - `addUser()` creates a fresh anonymous user and switches to it (sign in from there to add an account).
170
+ - `removeUser(id)` wipes that user's local data and revokes its session; removing the active user is the same as `signOut()`.
171
+ - `signOut()` removes the active user and falls through to the next one (or a fresh anonymous workspace).
152
172
 
153
- When `dbMode === "sync"`, `dbStatus` is one of:
173
+ ## Authentication
154
174
 
155
- | Value | Description |
156
- | ------------------ | ----------------------------------------------------------------------------------------------------------------- |
157
- | `LOADING` | SDK initializing |
158
- | `OFFLINE` | Not connected |
159
- | `CONNECTING` | Connecting to sync server |
160
- | `ONLINE` | Connected and idle |
161
- | `SYNCING` | Syncing data |
162
- | `ERROR` | Sync error |
163
- | `ERROR_WILL_RETRY` | Sync error but client will retry (e.g. expired token). Use this to show "Reconnecting…" or trigger token refresh. |
175
+ Basic is the OAuth provider. `signIn()` redirects to the user's Basic sign-in page; after consent the user lands back on your app with a code the SDK exchanges automatically (PKCE, rotating refresh tokens — all handled for you).
164
176
 
165
- Import the enum for comparisons: `import { useBasic, DBStatus } from '@basictech/react'`.
177
+ ```tsx
178
+ const auth = useAuth(); // or grab the same fields from useBasic()
179
+
180
+ auth.status; // 'bootstrapping' | 'authenticated' | 'recovering' | 'reauth_required' | 'signed_out'
181
+ auth.isSignedIn; // boolean (stays true during reauth_required so you can show user info)
182
+ auth.user; // { sub, email, name, picture } | null
183
+ auth.did; // the user's decentralized id
184
+ auth.scope; // scopes granted to your app
185
+
186
+ await auth.signIn(); // redirect flow (current URL is the redirect URI)
187
+ await auth.signIn("https://app.com/done"); // custom redirect URI
188
+ await auth.signInWithHandle("alice.basic.id"); // federated: resolve the user's own PDS first
189
+ await auth.signOut(); // revokes server-side + clears local data
190
+ ```
166
191
 
167
- ---
192
+ ### Auth status lifecycle
168
193
 
169
- ### Development toolbar
194
+ | Status | Meaning | What to render |
195
+ | --- | --- | --- |
196
+ | `bootstrapping` | SDK is initializing | splash / nothing (provider hides children by default) |
197
+ | `authenticated` | Healthy session | your app |
198
+ | `recovering` | Session likely exists but unconfirmed (offline, mid-refresh) | your app (data still works locally) |
199
+ | `reauth_required` | Session definitively invalid — user must sign in again | your app (local reads/writes still work) + a re-sign-in prompt |
200
+ | `signed_out` | No session — anonymous local workspace (default mode) | your app + a "sign in to sync" prompt (`isAnonymous` is true) |
170
201
 
171
- A small floating bar (similar in spirit to Next.js dev indicators) shows **auth**, **database mode**, **sync status**, and **schema vs server** health. It is **opt-in** and only appears in development: `localhost` / `127.0.0.1` / `.local`, `NODE_ENV === "development"`, or when `debug` is `true` on the provider or on the standalone component.
202
+ ### Calling your own API
172
203
 
173
- **Option Aprovider flag**
204
+ `getToken()` returns a valid access token, refreshing it automatically (tokens are short-lived never cache them yourself):
174
205
 
175
- ```tsx
176
- <BasicProvider schema={schema} devToolbar debug>
177
- <App />
178
- </BasicProvider>
206
+ ```typescript
207
+ const token = await auth.getToken();
208
+ await fetch("https://your-api.example/thing", {
209
+ headers: { Authorization: `Bearer ${token}` },
210
+ });
211
+ // after a 401 from your API:
212
+ await auth.getToken({ forceRefresh: true });
179
213
  ```
180
214
 
181
- **Option B — place the component yourself** (must be under `BasicProvider`; respects the same visibility rules, or pass `debug` to force):
215
+ ### Scopes
182
216
 
183
- ```tsx
184
- import { BasicDevToolbar } from "@basictech/react";
217
+ Default requested scopes are `profile,email,app:admin`. Check what was actually granted:
185
218
 
186
- <BasicProvider schema={schema}>
187
- <App />
188
- <BasicDevToolbar />
189
- </BasicProvider>;
219
+ ```typescript
220
+ auth.hasScope("app:db:read"); // note: app:admin implies all app:db:* scopes
221
+ auth.missingScopes(); // requested but not granted
190
222
  ```
191
223
 
192
- The expanded panel includes **Refresh schema** (re-runs the remote schema check) and **Copy debug info** (JSON snapshot **without** raw access tokens). You can also read `devInfo` and call `refreshSchemaStatus()` from `useBasic()` for your own UI.
224
+ ## Database
193
225
 
194
- ---
226
+ `db.table(name)` returns the table API (name must exist in your schema):
195
227
 
196
- ### `useQuery()`
228
+ ```typescript
229
+ const todos = db.table("todos");
197
230
 
198
- Live query hook - automatically re-renders when data changes.
231
+ const created = await todos.create({ title: "buy milk" });
232
+ // → { id: "0198…", title: "buy milk" } — id minted locally, works offline
199
233
 
200
- ```tsx
201
- import { useQuery } from "@basictech/react";
234
+ await todos.put(id, { title: "replace", completed: false });
235
+ // create-or-replace the WHOLE record with these fields
202
236
 
203
- // Get all items
204
- const todos = useQuery(() => db.collection("todos").getAll());
237
+ await todos.patch(id, { completed: true });
238
+ // shallow-merge the given fields; returns null if the record doesn't exist
205
239
 
206
- // With type safety
207
- interface Todo {
208
- id: string;
209
- title: string;
210
- completed: boolean;
211
- }
212
- const todos = useQuery(() => db.collection<Todo>("todos").getAll());
213
- ```
240
+ await todos.delete(id); // idempotent deleting a missing record is fine
214
241
 
215
- > **Note:** Only works in `sync` mode. In `remote` mode, use manual fetching.
242
+ await todos.get(id); // record or null
243
+ await todos.getAll(); // all records
244
+ await todos.find((t) => !t.completed);
245
+ ```
216
246
 
217
- ---
247
+ Semantics worth knowing:
218
248
 
219
- ### Database Methods
249
+ - **`put` replaces, `patch` merges.** Two devices patching *different* fields of the same record both win; patching the *same* field, the last write wins.
250
+ - **`json` fields are atomic** — patching a `json` field replaces its whole value (no deep merge).
251
+ - Writes are **validated locally** against your schema before queueing (same rules the server enforces), so typos fail fast with a thrown error instead of a round-trip.
252
+ - In sync mode, `create` mints the id client-side — you get the full record back synchronously-ish, even offline.
220
253
 
221
- #### `db.collection(name)`
254
+ ## Live queries
222
255
 
223
- Access a collection by name.
256
+ `useQuery` re-runs your query and re-renders whenever the underlying data changes:
224
257
 
225
258
  ```tsx
226
- const { db } = useBasic();
227
- const todos = db.collection("todos");
259
+ const todos = useQuery(() => db.table("todos").getAll()); // undefined while loading
260
+ const open = useQuery(() => db.table("todos").find((t) => !t.completed));
261
+ const one = useQuery(() => db.table("todos").get(selectedId), [selectedId]); // deps like useEffect
228
262
  ```
229
263
 
230
- #### Collection Methods
264
+ For advanced queries, `table.ref` exposes the underlying [Dexie](https://dexie.org) table over the local view (sync mode only) — indexed fields from your schema are Dexie indexes:
231
265
 
232
- | Method | Returns | Description |
233
- | ------------------ | -------------------- | ----------------------------------- |
234
- | `getAll()` | `Promise<T[]>` | Get all records |
235
- | `get(id)` | `Promise<T \| null>` | Get one record by ID |
236
- | `add(data)` | `Promise<T>` | Create new record (returns with ID) |
237
- | `put(data)` | `Promise<T>` | Upsert record (requires ID) |
238
- | `update(id, data)` | `Promise<T \| null>` | Partial update |
239
- | `delete(id)` | `Promise<boolean>` | Delete record |
240
- | `filter(fn)` | `Promise<T[]>` | Filter with predicate |
266
+ ```tsx
267
+ const recent = useQuery(() =>
268
+ db.table("todos").ref!.where("completed").equals(0).limit(10).toArray(),
269
+ );
270
+ ```
241
271
 
242
- #### Examples
272
+ ## Sync status, offline behavior & errors
243
273
 
244
274
  ```tsx
245
- // Create
246
- const todo = await db.collection("todos").add({
247
- title: "Buy milk",
248
- completed: false,
249
- });
250
- console.log(todo.id); // Auto-generated ID
275
+ const sync = useSyncStatus();
251
276
 
252
- // Read
253
- const allTodos = await db.collection("todos").getAll();
254
- const oneTodo = await db.collection("todos").get("some-id");
277
+ sync.status; // 'idle' | 'local' | 'connecting' | 'online' | 'offline' | 'auth_required' | 'revoked' | 'stopped'
278
+ sync.enabled; // false when the schema is invalid/unpublished
279
+ sync.pendingCount; // local writes not yet confirmed by the server
280
+ ```
255
281
 
256
- // Update
257
- await db.collection("todos").update("some-id", { completed: true });
282
+ `local` means the database is working without a connection — an anonymous workspace, an unpublished (version 0) schema during development, or a paused session.
258
283
 
259
- // Delete
260
- await db.collection("todos").delete("some-id");
284
+ **Offline is not an error.** Writes keep working and queue in IndexedDB; reads serve the local view; everything pushes on reconnect (safe to retry — ops are idempotent). The queue survives page reloads — including offline cold starts: reloading with no network serves all data from the local replica.
261
285
 
262
- // Filter
263
- const incomplete = await db.collection("todos").filter((t) => !t.completed);
286
+ `revoked` means the user disconnected your app from their account — data access is gone until they sign in and consent again.
287
+
288
+ ### Rejected ops
289
+
290
+ If the server terminally rejects a write (schema validation, permissions, payload too large), the SDK **rolls the record back**, parks the op in a rejected store, and will never retry it:
291
+
292
+ ```tsx
293
+ const sync = useSyncStatus();
294
+ const rejected = await sync.listRejected();
295
+ // [{ op_id, op: { type, table, record_id, data }, error: 'SCHEMA_VALIDATION_FAILED', message, rejected_at }]
296
+ await sync.clearRejected();
264
297
  ```
265
298
 
266
- ---
299
+ You can also listen live:
267
300
 
268
- ## Advanced Usage
301
+ ```typescript
302
+ const { client } = useBasic();
303
+ useEffect(() => client.engine?.on("rejected", ({ rejection }) => {
304
+ toast.error(`write rejected: ${rejection.error}`);
305
+ }), [client]);
306
+ ```
269
307
 
270
- ### Manual OAuth Flow
308
+ ## Shares (multiplayer)
271
309
 
272
- For custom OAuth handling (mobile apps, popups, etc.):
310
+ Users can grant other users access to a slice of their data — a whole table or specific records, read-only or read-write. Grants are created by the user on basic.id (apps can never share a user's data on their own); your app **lists** and **mounts** them:
273
311
 
274
312
  ```tsx
275
- const { signInWithCode, getSignInUrl } = useBasic();
313
+ function SharedTodos() {
314
+ const { received } = useShares(); // shares other users granted to me
315
+ const share = received[0];
316
+ const { db: sharedDb, status } = useShare(share?.id); // 'mounting' | 'mounted' | 'error' | 'revoked'
317
+ const todos = useQuery(() => sharedDb?.table("todos").getAll(), [sharedDb]);
276
318
 
277
- // Get OAuth URL with custom redirect
278
- const url = getSignInUrl("myapp://callback");
319
+ if (!share) return <p>nothing shared with you yet</p>;
320
+ if (status !== "mounted") return <p>{status}…</p>;
279
321
 
280
- // Exchange code for session
281
- const result = await signInWithCode(code, state);
282
- if (result.success) {
283
- console.log("Signed in!");
322
+ return (
323
+ <ul>
324
+ {todos?.map((t) => (
325
+ <li key={t.id} onClick={() => sharedDb!.table("todos").patch(t.id, { completed: true })}>
326
+ {String(t.title)}
327
+ </li>
328
+ ))}
329
+ </ul>
330
+ );
284
331
  }
285
332
  ```
286
333
 
287
- ### Remote Mode
334
+ Mounted shares live in their **own local keyspace** — separate replica, separate offline queue — and are never merged with the user's own data. Writes work when the share permission is `write` (the owner sees your changes attributed to the guest). When the owner revokes the share, the mount status flips to `revoked` and the local copy is purged.
288
335
 
289
- For server-rendered apps or when you don't need offline support:
336
+ ## Typed schemas
290
337
 
291
- ```tsx
292
- <BasicProvider schema={schema} dbMode="remote">
293
- <App />
294
- </BasicProvider>
295
- ```
338
+ `@basictech/schema` (a dependency of this package) can infer record types from your schema:
296
339
 
297
- In remote mode:
340
+ ```typescript
341
+ import { defineSchema, type InferRecord } from "@basictech/schema";
298
342
 
299
- - Data is fetched via REST API
300
- - No IndexedDB storage
301
- - `useQuery` won't auto-update (use manual refresh)
302
- - Requires authentication for all operations
303
- - Auth request failures are surfaced to the caller; recoverable 401s do not automatically sign the user out
343
+ export const schema = defineSchema({
344
+ project_id: "…",
345
+ version: 1,
346
+ tables: {
347
+ todos: {
348
+ fields: {
349
+ title: { type: "string", required: true },
350
+ completed: { type: "boolean" },
351
+ },
352
+ },
353
+ },
354
+ });
355
+
356
+ type Todo = InferRecord<typeof schema, "todos">;
357
+ // { id: string; title: string; completed?: boolean }
358
+
359
+ const todos = db.table<Todo>("todos"); // fully typed CRUD
360
+ ```
304
361
 
305
- ### Error Handling
362
+ ## REST mode
363
+
364
+ Don't want a local replica? `mode="rest"` makes every table call a direct API request — no IndexedDB, no WebSocket, no offline support:
306
365
 
307
366
  ```tsx
308
- import { NotAuthenticatedError } from "@basictech/react";
309
-
310
- try {
311
- await db.collection("todos").add({ title: "Test" });
312
- } catch (error) {
313
- if (error instanceof NotAuthenticatedError) {
314
- // User needs to sign in
315
- signIn();
316
- }
317
- }
367
+ <BasicProvider schema={schema} mode="rest">
318
368
  ```
319
369
 
320
- ---
370
+ The table API is identical, with REST semantics: `create` gets its id from the server, `put`/`patch`/`delete` on missing records return null/no-op, reads hit the network. `useQuery` won't live-update in this mode (re-run queries with deps).
321
371
 
322
- ## TypeScript
372
+ ## API reference
323
373
 
324
- Full TypeScript support with generics:
374
+ ### `<BasicProvider>` props
325
375
 
326
- ```tsx
327
- interface Todo {
328
- id: string;
329
- title: string;
330
- completed: boolean;
331
- createdAt: number;
332
- }
376
+ | Prop | Default | Description |
377
+ | --- | --- | --- |
378
+ | `schema` | — | Your Basic schema document (`project_id` is read from it) |
379
+ | `mode` | `"sync"` | `"sync"` (local replica) or `"rest"` (direct API) |
380
+ | `anonymous` | `true` | Local workspace without sign-in; merges into the account on sign-in |
381
+ | `auth.scopes` | `"profile,email,app:admin"` | OAuth scopes to request |
382
+ | `auth.pds_url` | `https://pds.basic.id` | The Basic server (auth + data + sync) |
383
+ | `auth.sync_url` | `wss://<pds>/sync/` | Sync WebSocket URL (rarely needed) |
384
+ | `storage` | `localStorage` | Custom `BasicStorage` adapter for auth state |
385
+ | `debug` | `false` | Verbose console logging |
386
+ | `devToolbar` | `false` | Floating status toolbar (dev environments only) |
387
+ | `renderWhileLoading` | `false` | Render children before auth bootstrap finishes |
333
388
 
334
- // Type-safe collection
335
- const todos = db.collection<Todo>("todos");
389
+ ### Hooks
336
390
 
337
- // All methods are typed
338
- const todo = await todos.add({
339
- title: "Test",
340
- completed: false,
341
- createdAt: Date.now(),
342
- });
343
- // todo is typed as Todo
391
+ | Hook | Returns |
392
+ | --- | --- |
393
+ | `useBasic()` | Everything: auth fields + actions, `db`, `sync`, `users`, `activeUser`, `devInfo`, `client` |
394
+ | `useAuth()` | `{ isReady, isSignedIn, isAnonymous, status, user, did, scope, hasScope, missingScopes, signIn, signInWithHandle, signInWithCode, signOut, getToken, getSignInUrl }` |
395
+ | `useDb()` | The table API for the active user's own data |
396
+ | `useQuery(fn, deps?)` | Live query result (`undefined` while loading); re-attaches automatically on user switch |
397
+ | `useSyncStatus()` | `{ status, enabled, pendingCount, listRejected, clearRejected }` |
398
+ | `useUsers()` | `{ users, activeUser, isAnonymous, switchUser, addUser, removeUser }` |
399
+ | `useShares()` | `{ granted, received, isLoading, error, refresh }` |
400
+ | `useShare(shareId)` | `{ db, status, error }` — a mounted share |
401
+
402
+ Note on `useQuery` and user switching: the active user's identity is appended to your deps automatically, so queries that read `db` from the current render (via `useBasic()`/`useDb()`) re-attach to the new user's database on switch. If your query closure captures `db` from outside render, pass `[db]` in deps explicitly — or remount the subtree with `key={activeUser?.id}`.
403
+
404
+ ### Table API
405
+
406
+ | Method | Sync mode | REST mode |
407
+ | --- | --- | --- |
408
+ | `create(data)` | client-minted id, offline-capable | server-minted id |
409
+ | `put(id, data)` | create **or replace** | replace; throws if missing |
410
+ | `patch(id, partial)` | merge; `null` if missing locally | merge; `null` if missing |
411
+ | `delete(id)` | idempotent | no-op if missing |
412
+ | `get(id)` / `getAll()` / `find(fn)` | local view | network |
413
+ | `ref` | Dexie table (live queries, indexes) | `undefined` |
414
+
415
+ ## Advanced usage
416
+
417
+ ### Outside React
418
+
419
+ Everything is available without React — `BasicClient` is the framework-agnostic core (`AuthManager` + `SyncEngine` + `RestClient`):
420
+
421
+ ```typescript
422
+ import { createBasicClient } from "@basictech/react";
423
+
424
+ const client = createBasicClient({ schema, debug: true });
425
+ await client.start();
426
+ client.subscribe(() => console.log(client.getSnapshot()));
427
+ await client.db.table("todos").create({ title: "from anywhere" });
344
428
  ```
345
429
 
430
+ The Sync/2 protocol layer (`SyncEngine`, `SyncConnection`, `SyncStore`, and all wire types) is exported too, for building custom clients or other runtimes — pass a `WebSocketImpl` (e.g. the `ws` package) to run in Node.
431
+
432
+ ### Events
433
+
434
+ ```typescript
435
+ const { client } = useBasic();
436
+ client.engine?.on("status", (s) => {}); // sync status changes
437
+ client.engine?.on("change", ({ sub, tables }) => {}); // records changed
438
+ client.engine?.on("rejected", ({ sub, rejection }) => {}); // terminal write rejection
439
+ client.engine?.on("revoked", ({ code }) => {}); // app connection revoked
440
+ ```
441
+
442
+ ### Custom storage
443
+
444
+ Auth state lives in `localStorage` by default. Provide any `{ get, set, remove }` async adapter via the `storage` prop (see `BasicStorage`). `STORAGE_KEYS` lists every key the SDK uses.
445
+
446
+ ## Migrating from 0.8
447
+
448
+ 0.9 is a breaking rewrite — the server's legacy sync endpoint was removed, so 0.8 sync no longer functions. Changes you'll make:
449
+
450
+ | 0.8 | 0.9 |
451
+ | --- | --- |
452
+ | `db.collection("todos")` | `db.table("todos")` |
453
+ | `.add(data)` | `.create(data)` |
454
+ | `.update(id, partial)` | `.patch(id, partial)` |
455
+ | `.put({ id, ...fields })` | `.put(id, fields)` |
456
+ | `dbStatus` / `DBStatus` enum | `useSyncStatus().status` |
457
+ | `dbMode="remote"` | `mode="rest"` |
458
+ | `auth.ws_url` | `auth.sync_url` (you almost never need it) |
459
+ | `signOut()` reloads the page | in-place teardown, no reload |
460
+
461
+ Local data migrates by re-bootstrap: the old `basicdb` IndexedDB database is deleted automatically and 0.9 builds fresh replicas in `basic-sync:<project_id>` databases from the server (the server is the source of truth, so nothing is lost).
462
+
346
463
  ---
347
464
 
348
465
  ## License