@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/changelog.md +65 -0
- package/dist/index.d.mts +1258 -285
- package/dist/index.d.ts +1258 -285
- package/dist/index.js +2898 -1425
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2858 -1418
- package/dist/index.mjs.map +1 -1
- package/package.json +27 -8
- package/readme.md +331 -214
- package/.turbo/turbo-build.log +0 -24
- package/AUTH_IMPLEMENTATION_GUIDE.md +0 -2011
- package/src/AuthContext.tsx +0 -591
- package/src/config.ts +0 -9
- package/src/context.tsx +0 -122
- package/src/core/auth/AuthManager.ts +0 -1371
- package/src/core/db/RemoteCollection.ts +0 -308
- package/src/core/db/RemoteDB.ts +0 -40
- package/src/core/db/index.ts +0 -7
- package/src/core/db/types.ts +0 -140
- package/src/dev/BasicDevToolbar.tsx +0 -665
- package/src/index.ts +0 -36
- package/src/sync/index.ts +0 -288
- package/src/sync/syncProtocol.js +0 -291
- package/src/sync/tokenRegistry.ts +0 -20
- package/src/updater/updateMigrations.ts +0 -22
- package/src/updater/versionUpdater.ts +0 -153
- package/src/utils/network.ts +0 -135
- package/src/utils/normalizeClientId.ts +0 -22
- package/src/utils/resolveDid.ts +0 -101
- package/src/utils/schema.ts +0 -119
- package/src/utils/storage.ts +0 -67
- package/tsconfig.json +0 -9
- package/tsup.config.ts +0 -11
package/readme.md
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
# @basictech/react
|
|
2
2
|
|
|
3
|
-
React SDK for [Basic](https://basic.tech)
|
|
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
|
-
|
|
37
|
+
Requires React 16.8+ (hooks). React 17, 18, and 19 are supported.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
12
40
|
|
|
13
|
-
### 1.
|
|
41
|
+
### 1. Define your schema
|
|
14
42
|
|
|
15
|
-
Create a `basic.config.ts`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
);
|
|
47
|
-
}
|
|
71
|
+
createRoot(document.getElementById("root")!).render(
|
|
72
|
+
<BasicProvider schema={schema} devToolbar>
|
|
73
|
+
<App />
|
|
74
|
+
</BasicProvider>,
|
|
75
|
+
);
|
|
48
76
|
```
|
|
49
77
|
|
|
50
|
-
### 3.
|
|
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
|
|
58
|
-
const {
|
|
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
|
|
88
|
+
return <button onClick={() => signIn()}>Sign in with Basic</button>;
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
return (
|
|
75
92
|
<div>
|
|
76
|
-
<p>
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
##
|
|
128
|
+
## Anonymous & local-first
|
|
92
129
|
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
// works signed in or not:
|
|
138
|
+
await db.table("todos").create({ title: "works offline & anonymous" });
|
|
107
139
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
144
|
+
Safety guarantees:
|
|
116
145
|
|
|
117
|
-
-
|
|
118
|
-
-
|
|
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
|
-
|
|
151
|
+
## Multiple users
|
|
123
152
|
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
## Authentication
|
|
154
174
|
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
+
### Calling your own API
|
|
172
203
|
|
|
173
|
-
|
|
204
|
+
`getToken()` returns a valid access token, refreshing it automatically (tokens are short-lived — never cache them yourself):
|
|
174
205
|
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
215
|
+
### Scopes
|
|
182
216
|
|
|
183
|
-
|
|
184
|
-
import { BasicDevToolbar } from "@basictech/react";
|
|
217
|
+
Default requested scopes are `profile,email,app:admin`. Check what was actually granted:
|
|
185
218
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
224
|
+
## Database
|
|
193
225
|
|
|
194
|
-
|
|
226
|
+
`db.table(name)` returns the table API (name must exist in your schema):
|
|
195
227
|
|
|
196
|
-
|
|
228
|
+
```typescript
|
|
229
|
+
const todos = db.table("todos");
|
|
197
230
|
|
|
198
|
-
|
|
231
|
+
const created = await todos.create({ title: "buy milk" });
|
|
232
|
+
// → { id: "0198…", title: "buy milk" } — id minted locally, works offline
|
|
199
233
|
|
|
200
|
-
|
|
201
|
-
|
|
234
|
+
await todos.put(id, { title: "replace", completed: false });
|
|
235
|
+
// create-or-replace the WHOLE record with these fields
|
|
202
236
|
|
|
203
|
-
|
|
204
|
-
|
|
237
|
+
await todos.patch(id, { completed: true });
|
|
238
|
+
// shallow-merge the given fields; returns null if the record doesn't exist
|
|
205
239
|
|
|
206
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
+
## Live queries
|
|
222
255
|
|
|
223
|
-
|
|
256
|
+
`useQuery` re-runs your query and re-renders whenever the underlying data changes:
|
|
224
257
|
|
|
225
258
|
```tsx
|
|
226
|
-
const
|
|
227
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
272
|
+
## Sync status, offline behavior & errors
|
|
243
273
|
|
|
244
274
|
```tsx
|
|
245
|
-
|
|
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
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
+
## Shares (multiplayer)
|
|
271
309
|
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
319
|
+
if (!share) return <p>nothing shared with you yet</p>;
|
|
320
|
+
if (status !== "mounted") return <p>{status}…</p>;
|
|
279
321
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
+
## Typed schemas
|
|
290
337
|
|
|
291
|
-
|
|
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
|
-
|
|
340
|
+
```typescript
|
|
341
|
+
import { defineSchema, type InferRecord } from "@basictech/schema";
|
|
298
342
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
372
|
+
## API reference
|
|
323
373
|
|
|
324
|
-
|
|
374
|
+
### `<BasicProvider>` props
|
|
325
375
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
335
|
-
const todos = db.collection<Todo>("todos");
|
|
389
|
+
### Hooks
|
|
336
390
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|