@basictech/react 0.9.0-beta.0 → 0.11.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/LICENSE +21 -0
- package/README.md +1101 -0
- package/dist/index.d.mts +236 -1119
- package/dist/index.d.ts +236 -1119
- package/dist/index.js +1010 -4117
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +980 -4070
- package/dist/index.mjs.map +1 -1
- package/package.json +23 -20
- package/changelog.md +0 -408
- package/readme.md +0 -413
package/README.md
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
# @basictech/react
|
|
2
|
+
|
|
3
|
+
React and browser integration for [Basic](https://basic.tech): typed data, local-first sync,
|
|
4
|
+
authentication, accounts, files, repos, and sharing.
|
|
5
|
+
|
|
6
|
+
`@basictech/react` supplies the browser adapters, a React provider, and schema-bound hooks. The
|
|
7
|
+
framework-independent client and protocol types live in `@basictech/core`; schema definition and
|
|
8
|
+
runtime validation live in `@basictech/schema`.
|
|
9
|
+
|
|
10
|
+
| Package | Role |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `@basictech/react` | Browser defaults, React provider, schema-bound factory, and hooks |
|
|
13
|
+
| `@basictech/core` | Framework-neutral client, data model, sync, files, shares, and public types |
|
|
14
|
+
| `@basictech/schema/define` | Dependency-light schema definition and TypeScript inference |
|
|
15
|
+
|
|
16
|
+
This beta requires React 18 or newer (`useSyncExternalStore`) and declares Node.js 24 or newer in
|
|
17
|
+
its package metadata.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Install the React SDK and declare the schema package you import directly:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @basictech/react @basictech/schema
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
React is a peer dependency and must already be installed by your application.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
Define the schema and create `basic` once at module scope. The returned client, Provider, and hooks
|
|
32
|
+
all carry the schema type.
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
// file: QuickStart.tsx
|
|
36
|
+
import { createBasic } from '@basictech/react'
|
|
37
|
+
import { defineSchema } from '@basictech/schema/define'
|
|
38
|
+
|
|
39
|
+
const schema = defineSchema({
|
|
40
|
+
project_id: 'my-project',
|
|
41
|
+
version: 1,
|
|
42
|
+
tables: {
|
|
43
|
+
todos: {
|
|
44
|
+
fields: {
|
|
45
|
+
title: { type: 'string', required: true },
|
|
46
|
+
done: { type: 'boolean', required: true },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const basic = createBasic({
|
|
53
|
+
schema,
|
|
54
|
+
clientId: 'did:web:app.example',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function Todos() {
|
|
58
|
+
const auth = basic.useAuth()
|
|
59
|
+
const todos = basic.useCollection('todos')
|
|
60
|
+
const result = basic.useQuery('todos', { sort: 'title' })
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<main>
|
|
64
|
+
{auth.isAnonymous && (
|
|
65
|
+
<button onClick={() => void auth.signIn()}>Sign in to sync</button>
|
|
66
|
+
)}
|
|
67
|
+
{auth.isSignedIn && (
|
|
68
|
+
<button onClick={() => void auth.signOut()}>Sign out {auth.user?.email}</button>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
<button onClick={() => void todos.create({ title: 'Try Basic', done: false })}>
|
|
72
|
+
Add todo
|
|
73
|
+
</button>
|
|
74
|
+
|
|
75
|
+
{result.isLoading && <p>Loading…</p>}
|
|
76
|
+
{result.error && <p>Could not load todos: {result.error.code}</p>}
|
|
77
|
+
<ul>
|
|
78
|
+
{result.data.map((todo) => <li key={todo.id}>{todo.value?.title}</li>)}
|
|
79
|
+
</ul>
|
|
80
|
+
</main>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default function Root() {
|
|
85
|
+
return <basic.Provider><Todos /></basic.Provider>
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
With the default sync configuration, a new visitor receives an anonymous local workspace backed
|
|
90
|
+
by IndexedDB. `signIn()` starts redirect-only OAuth with PKCE; after the callback, the SDK replays
|
|
91
|
+
that profile's queued anonymous operations into the account's default repo.
|
|
92
|
+
|
|
93
|
+
## Define a schema
|
|
94
|
+
|
|
95
|
+
Use `defineSchema` from the dependency-light `@basictech/schema/define` subpath. It preserves
|
|
96
|
+
literal table and field names so `createBasic` can infer collection names, record values, filters,
|
|
97
|
+
and mutations throughout your app.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// file: basic.ts
|
|
101
|
+
import { createBasic } from '@basictech/react'
|
|
102
|
+
import { defineSchema } from '@basictech/schema/define'
|
|
103
|
+
|
|
104
|
+
export const schema = defineSchema({
|
|
105
|
+
project_id: 'tasks-web',
|
|
106
|
+
namespace: 'tasks',
|
|
107
|
+
version: 1,
|
|
108
|
+
tables: {
|
|
109
|
+
todos: {
|
|
110
|
+
fields: {
|
|
111
|
+
title: { type: 'string', required: true, indexed: true },
|
|
112
|
+
done: { type: 'boolean', required: true },
|
|
113
|
+
rank: { type: 'number' },
|
|
114
|
+
details: { type: 'json' },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
export const basic = createBasic({
|
|
121
|
+
schema,
|
|
122
|
+
clientId: 'did:web:app.example',
|
|
123
|
+
redirectUri: `${location.origin}/oauth/callback`,
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A schema has `project_id`, a numeric `version`, optional `namespace`, and a `tables` map. A table
|
|
128
|
+
has a `fields` map and may also define `name`, `type: 'collection'`, or
|
|
129
|
+
`origin: { type: 'reference', project_id, table, version? }`. Field definitions support:
|
|
130
|
+
|
|
131
|
+
| Option | Type | Effect on inferred values |
|
|
132
|
+
| --- | --- | --- |
|
|
133
|
+
| `type` | `'string' \| 'boolean' \| 'number' \| 'json'` | Required; maps to the corresponding TypeScript type (`json` maps to `unknown`) |
|
|
134
|
+
| `required` | `boolean` | `true` makes the value property required; otherwise it is optional |
|
|
135
|
+
| `indexed` | `boolean` | Declares an indexed schema field; it does not change the TypeScript value type |
|
|
136
|
+
|
|
137
|
+
Records are lifecycle envelopes: `{ id, value, meta }`. The schema describes `value`; the record
|
|
138
|
+
ID is the envelope's `id`, not a field inside `value`. `InferRecord` from the schema package includes
|
|
139
|
+
an `id` for standalone type use, while the React collection APIs infer the value without that ID.
|
|
140
|
+
|
|
141
|
+
## Configure `createBasic`
|
|
142
|
+
|
|
143
|
+
`createBasic(config)` requires a schema and returns one `CreatedBasic<S>`. Its config is the core
|
|
144
|
+
`BasicConfig<S>` plus a required `schema: S`. `BrowserBasicConfig<S>` is an alias of the same core
|
|
145
|
+
config; the React package changes behavior by installing browser defaults before creating the core
|
|
146
|
+
client.
|
|
147
|
+
|
|
148
|
+
### Configuration reference
|
|
149
|
+
|
|
150
|
+
| Option | Type | Browser default | When to override |
|
|
151
|
+
| --- | --- | --- | --- |
|
|
152
|
+
| `clientId` | `string` | Required | The application's client DID. Its Basic app metadata must register the redirect URI. |
|
|
153
|
+
| `schema` | `S extends BasicSchema` | Required by `createBasic` | Pass the `defineSchema()` result used by this application. It is optional only in the lower-level `createBasicClient`. |
|
|
154
|
+
| `identityOrigin` | `string` | `'https://basic.id'` | Use another Identity v1 origin for handle resolution and the shares management URL. |
|
|
155
|
+
| `defaultPds` | `string` | `'https://pds.basic.id'` | Change the PDS discovered by no-argument `signIn()`. `signIn(handleOrDid)` discovers that identity's PDS instead. |
|
|
156
|
+
| `redirectUri` | `string` | `location.origin + location.pathname` | Set an explicit OAuth callback route. Outside a browser location, it is required. The URI must appear in the client metadata for `clientId`. |
|
|
157
|
+
| `mode` | `'sync' \| 'rest'` | `'sync'` | Choose direct REST when offline replicas, anonymous mode, multi-account state, and `watch()` are not needed. |
|
|
158
|
+
| `anonymous` | `boolean` | `true` in sync mode | Set `false` to require sign-in. REST mode always disables anonymous workspaces. |
|
|
159
|
+
| `scopes` | `string[]` | `['openid:read']` | Request additional OAuth scopes. `openid:read` is added and duplicates are removed. |
|
|
160
|
+
| `conflictPolicy` | `'keep-mine' \| 'keep-theirs' \| 'event'` | `'keep-mine'` | Keep the local intent, drop it in favor of remote state, or leave conflicts pending for explicit resolution. |
|
|
161
|
+
| `debug` | `boolean` | `false` | Enable selected client lifecycle diagnostics through `console.debug`. |
|
|
162
|
+
| `allowInsecure` | `boolean` | `false` | Allow HTTP identity/PDS endpoints in controlled local development only. HTTPS is otherwise required. |
|
|
163
|
+
| `fetch` | `typeof globalThis.fetch` | `globalThis.fetch` | Inject a fetch implementation, test transport, or instrumented wrapper. |
|
|
164
|
+
| `WebSocketImpl` | `new (url: string \| URL) => WebSocket` | `globalThis.WebSocket` | Inject WebSocket support in a non-browser runtime or tests. |
|
|
165
|
+
| `kv` | `KeyValueStorage` | `localStorage`, then memory | Replace profile registry, cached identity/repo state, and other durable key/value state. |
|
|
166
|
+
| `tokenStore` | `TokenStore` | `BrowserTokenStore` over the default local store | Supply a secure credential facility. See [Token storage](#token-storage-beta-policy). |
|
|
167
|
+
| `sessionStorage` | `KeyValueStorage` | `sessionStorage`, then memory | Replace per-tab active-profile selection and one-time PKCE state storage. |
|
|
168
|
+
| `replicaStore` | `ReplicaStoreFactory` | Dexie/IndexedDB, then memory | Provide another durable replica store or an explicit memory store for tests. |
|
|
169
|
+
| `navigate` | `(url: string) => void \| Promise<void>` | `location.assign(url)` | Integrate OAuth navigation with a browser shell or test harness. |
|
|
170
|
+
| `currentUrl` | `() => string` | `location.href`, or `''` without `location` | Tell auth bootstrap where to read OAuth `code` and `state`. |
|
|
171
|
+
| `replaceUrl` | `(url: string) => void` | `history.replaceState({}, '', url)` | Integrate removal of consumed OAuth query parameters with custom navigation. |
|
|
172
|
+
| `createMessageChannel` | `(name: string) => AuthMessageChannel` | `BroadcastChannel` when available | Replace or disable cross-context auth notifications. The default token adapter also transfers access tokens transiently between tabs. |
|
|
173
|
+
| `ownerCredential` | `OwnerCredentialAdapter` | None | First-party Basic ID/dev tooling can inject owner credentials for `client.drive()`, account-wide `client.storageInfo()`, and `client.shares.leave()`. Normal app credentials should use existing repos and `shares.manageUrl()`. |
|
|
174
|
+
| `uploadTransport` | `UploadTransportAdapter` | Browser XHR, then the core fetch fallback | Supply a multipart transport. XHR is used by default because fetch has no upload-progress events. |
|
|
175
|
+
| `warn` | `(message: string) => void` | `console.warn` for schema checks | Route schema-drift warnings into application logging. |
|
|
176
|
+
|
|
177
|
+
`KeyValueStorage` and `TokenStore` implement `get(key)`, `set(key, value)`, and `remove(key)`; each
|
|
178
|
+
method may be synchronous or return a Promise.
|
|
179
|
+
|
|
180
|
+
### Browser persistence and cross-tab behavior
|
|
181
|
+
|
|
182
|
+
The defaults are selected from browser capabilities at client creation:
|
|
183
|
+
|
|
184
|
+
- **Replica persistence:** sync replicas, pending operations, rejections, and lifecycle metadata
|
|
185
|
+
are stored in IndexedDB through Dexie. If IndexedDB is unavailable, replicas are memory-only.
|
|
186
|
+
- **Profile state:** the account registry and per-profile state use `localStorage`; active profile
|
|
187
|
+
selection and PKCE state use `sessionStorage`. Inaccessible storage falls back to memory.
|
|
188
|
+
- **Single writer:** the persistence adapter uses Web Locks for per-partition leader election and
|
|
189
|
+
`BroadcastChannel` to forward follower mutations and publish committed replica state. Without
|
|
190
|
+
both primitives, every tab may write in a degraded version-guarded, last-committed-writer-wins
|
|
191
|
+
mode; concurrent offline intent can be lost in that fallback.
|
|
192
|
+
- **Auth coordination:** token rotation and sign-out notifications use `BroadcastChannel` when
|
|
193
|
+
available. Access tokens can cross that channel transiently but are not written by the default
|
|
194
|
+
token store.
|
|
195
|
+
- **Uploads:** browser multipart uploads use XHR for real `onProgress(loaded, total)` events. The
|
|
196
|
+
fetch fallback reports only start and completion.
|
|
197
|
+
|
|
198
|
+
The package also exports the adapters for explicit composition: `BrowserKeyValueStorage`,
|
|
199
|
+
`browserStorage`, `BrowserTokenStore`, `PersistenceStore`, `browserUploadTransport`,
|
|
200
|
+
`createBrowserMessageChannel`, `createBrowserAuthChannelFactory`, `browserNavigate`,
|
|
201
|
+
`browserCurrentUrl`, and `browserReplaceUrl`. Most applications only need `createBasic`, which
|
|
202
|
+
installs them.
|
|
203
|
+
|
|
204
|
+
### Token storage: beta policy
|
|
205
|
+
|
|
206
|
+
The default `BrowserTokenStore` stores each profile's refresh token in `localStorage` and keeps its
|
|
207
|
+
access token only in memory. This is the documented **CS-CLIENT-002 beta conformance exception**:
|
|
208
|
+
`localStorage` is not a secure credential facility. Inject `tokenStore` when the host provides a
|
|
209
|
+
secure store. This default is scheduled for reconsideration before GA.
|
|
210
|
+
|
|
211
|
+
## Provider
|
|
212
|
+
|
|
213
|
+
`basic.Provider` accepts exactly these props:
|
|
214
|
+
|
|
215
|
+
| Prop | Type | Default | Behavior |
|
|
216
|
+
| --- | --- | --- | --- |
|
|
217
|
+
| `children` | `ReactNode` | Required | The application subtree that can use Basic hooks |
|
|
218
|
+
| `renderWhileLoading` | `boolean` | `false` | Render children before `client.getSnapshot().isReady`; otherwise render nothing until startup settles |
|
|
219
|
+
|
|
220
|
+
Create `basic` once outside React rendering. On mount the Provider starts that client; on the final
|
|
221
|
+
unmount it stops it. Startup failures are thrown during rendering so a React error boundary can
|
|
222
|
+
handle them. The lifecycle is safe under React StrictMode's effect replay: references are counted,
|
|
223
|
+
duplicate starts share one Promise, and stop is deferred so the replay does not tear down a live
|
|
224
|
+
client.
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
// file: ProviderExample.tsx
|
|
228
|
+
import { StrictMode, type ReactNode } from 'react'
|
|
229
|
+
import { basic } from './basic'
|
|
230
|
+
|
|
231
|
+
export function BasicRoot({ children }: { children: ReactNode }) {
|
|
232
|
+
return (
|
|
233
|
+
<StrictMode>
|
|
234
|
+
<basic.Provider renderWhileLoading={false}>{children}</basic.Provider>
|
|
235
|
+
</StrictMode>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
With the default `renderWhileLoading={false}`, hooks under the Provider first render after the
|
|
241
|
+
client is ready. Setting it to `true` is useful for an application shell that reads `isReady` and
|
|
242
|
+
owns its loading UI.
|
|
243
|
+
|
|
244
|
+
## Hook reference
|
|
245
|
+
|
|
246
|
+
The schema-bound factory returns every hook below. Bound hooks are preferred because they reject
|
|
247
|
+
unknown table names and infer fields in reads, writes, and queries.
|
|
248
|
+
|
|
249
|
+
| Hook | Signature | Purpose |
|
|
250
|
+
| --- | --- | --- |
|
|
251
|
+
| `useAuth` | `useAuth(): UseAuthResult` | Auth state and redirect actions |
|
|
252
|
+
| `useAccounts` | `useAccounts(): UseAccountsResult` | Local profiles and per-tab switching |
|
|
253
|
+
| `useDb` | `useDb(source?: SourceRef): BasicDb` | Database for a default repo, explicit repo, or open mount |
|
|
254
|
+
| `useCollection` | `useCollection(name, { source? }?): Collection` | Typed collection CRUD |
|
|
255
|
+
| `useQuery` | `useQuery(name, query?, { source? }?): UseQueryResult` | Reactive typed query |
|
|
256
|
+
| `useSyncStatus` | `useSyncStatus(source?): UseSyncStatusResult` | Connectivity, pending work, rejections, and conflicts |
|
|
257
|
+
| `useSchemaStatus` | `useSchemaStatus(source?): SchemaStatus` | Local/server schema drift |
|
|
258
|
+
| `useRepos` | `useRepos(): UseReposResult` | Repo catalog operations |
|
|
259
|
+
| `useFiles` | `useFiles(query?, { source? }?): UseFilesResult` | Reactive file listing |
|
|
260
|
+
| `useStorageInfo` | `useStorageInfo(): UseStorageInfoResult` | Default repo storage use and quota |
|
|
261
|
+
| `useMounts` | `useMounts(query?): UseMountsResult` | Incoming mounts and mount opening |
|
|
262
|
+
| `useOutgoingShares` | `useOutgoingShares(): UseOutgoingSharesResult` | Outgoing share lifecycle |
|
|
263
|
+
| `useBasic` | `useBasic(): UseBasicResult` | Auth plus common client, db, account, sync, and repo state |
|
|
264
|
+
|
|
265
|
+
### `basic.useAuth()`
|
|
266
|
+
|
|
267
|
+
`useAuth()` returns:
|
|
268
|
+
|
|
269
|
+
| Field | Type | Meaning |
|
|
270
|
+
| --- | --- | --- |
|
|
271
|
+
| `isReady` | `boolean` | Auth bootstrap has left `bootstrapping` |
|
|
272
|
+
| `isSignedIn` | `boolean` | Status is `authenticated` or `recovering` |
|
|
273
|
+
| `isAnonymous` | `boolean` | The active sync profile is anonymous and not signed in |
|
|
274
|
+
| `status` | `'bootstrapping' \| 'signed_out' \| 'authenticated' \| 'recovering' \| 'reauth_required'` | Current auth lifecycle |
|
|
275
|
+
| `error` | `BasicError \| null` | Stable auth bootstrap/refresh error code when available |
|
|
276
|
+
| `user` | `AuthUser \| null` | OIDC user info (`sub`, `pds_url`, and optional `email`, `name`, `picture`, `handle`) |
|
|
277
|
+
| `did` | `string \| null` | Signed-in account DID |
|
|
278
|
+
| `handle` | `string \| null` | Resolved account handle |
|
|
279
|
+
| `signIn(input?)` | `(input?: string) => Promise<void>` | Begin redirect OAuth. Input may be a handle or DID; omit it to use `defaultPds`. |
|
|
280
|
+
| `signOut()` | `() => Promise<void>` | Best-effort revoke the refresh token, clear the local profile, and activate/create the next profile |
|
|
281
|
+
| `getToken()` | `() => Promise<string>` | Return or refresh an app access token; throws when unauthorized |
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
// file: AuthPanel.tsx
|
|
285
|
+
import { basic } from './basic'
|
|
286
|
+
|
|
287
|
+
export function AuthPanel() {
|
|
288
|
+
const auth = basic.useAuth()
|
|
289
|
+
|
|
290
|
+
if (!auth.isReady) return <p>Starting Basic…</p>
|
|
291
|
+
if (auth.isSignedIn) {
|
|
292
|
+
return (
|
|
293
|
+
<p>
|
|
294
|
+
Signed in as {auth.handle ?? auth.did}
|
|
295
|
+
<button onClick={() => void auth.signOut()}>Sign out</button>
|
|
296
|
+
</p>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div>
|
|
302
|
+
{auth.isAnonymous && <p>Your local work will migrate after sign-in.</p>}
|
|
303
|
+
<button onClick={() => void auth.signIn()}>Continue with Basic ID</button>
|
|
304
|
+
<button onClick={() => void auth.signIn('alice.basic.id')}>Sign in as Alice</button>
|
|
305
|
+
{auth.error && <p>{auth.error.code}</p>}
|
|
306
|
+
</div>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Anonymous mode exists only in sync mode. An anonymous profile has its own durable local replica.
|
|
312
|
+
When OAuth completes on that profile, its queued operations are replayed—with their operation IDs—
|
|
313
|
+
into the signed-in account's default repo. Signing out removes the active account profile's local
|
|
314
|
+
replicas and returns to another local profile or a fresh anonymous one when enabled.
|
|
315
|
+
|
|
316
|
+
### `basic.useAccounts()`
|
|
317
|
+
|
|
318
|
+
`useAccounts()` returns `{ accounts, active, switchAccount, addAccount, removeAccount }`:
|
|
319
|
+
|
|
320
|
+
- `accounts: BasicProfile[]` lists local profiles.
|
|
321
|
+
- `active: BasicProfile | null` is this tab's selected profile.
|
|
322
|
+
- `switchAccount(id): Promise<void>` changes the active profile in this tab.
|
|
323
|
+
- `addAccount(): Promise<BasicProfile>` creates and activates a new anonymous profile from which to
|
|
324
|
+
start another sign-in.
|
|
325
|
+
- `removeAccount(id): Promise<void>` removes that profile and its local credentials/replicas. If it
|
|
326
|
+
is active, this follows the sign-out path. It does not delete the server account.
|
|
327
|
+
|
|
328
|
+
`BasicProfile` contains `id`, `kind: 'anon' | 'account'`, `storagePrefix`, `createdAt`,
|
|
329
|
+
`lastActiveAt`, and optional/null `did`, `handle`, `email`, `name`, and `picture`. Multi-account and
|
|
330
|
+
`addAccount()` require sync mode with anonymous profiles enabled.
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
// file: AccountSwitcher.tsx
|
|
334
|
+
import { basic } from './basic'
|
|
335
|
+
|
|
336
|
+
export function AccountSwitcher() {
|
|
337
|
+
const { accounts, active, switchAccount, addAccount, removeAccount } = basic.useAccounts()
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<section>
|
|
341
|
+
<select
|
|
342
|
+
value={active?.id ?? ''}
|
|
343
|
+
onChange={(event) => void switchAccount(event.currentTarget.value)}
|
|
344
|
+
>
|
|
345
|
+
{accounts.map((account) => (
|
|
346
|
+
<option key={account.id} value={account.id}>
|
|
347
|
+
{account.handle ?? account.email ?? `Anonymous ${account.id.slice(0, 8)}`}
|
|
348
|
+
</option>
|
|
349
|
+
))}
|
|
350
|
+
</select>
|
|
351
|
+
<button onClick={() => void addAccount()}>Add account</button>
|
|
352
|
+
{active && <button onClick={() => void removeAccount(active.id)}>Remove local profile</button>}
|
|
353
|
+
</section>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### `basic.useDb()` and sources
|
|
359
|
+
|
|
360
|
+
`SourceRef` is a discriminated union:
|
|
361
|
+
|
|
362
|
+
| Source | Meaning |
|
|
363
|
+
| --- | --- |
|
|
364
|
+
| `'default'` or omitted | The active account's default repo, or the anonymous local repo |
|
|
365
|
+
| `{ repoId: string }` | A known repo in the active account's catalog |
|
|
366
|
+
| `{ mountId: string }` | An incoming mount already opened with `useMounts().open(mountId)` |
|
|
367
|
+
|
|
368
|
+
Bare repo/mount strings are not accepted. Unknown catalog entries throw `UNKNOWN_SOURCE`; the SDK
|
|
369
|
+
does not guess. The returned `BasicDb` has `kind: 'sync' | 'rest'`, `source`, `collection(name)`, and
|
|
370
|
+
`files`.
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
// file: DataSources.tsx
|
|
374
|
+
import { basic } from './basic'
|
|
375
|
+
|
|
376
|
+
export function DataSources({ repoId, openMountId }: { repoId: string; openMountId: string }) {
|
|
377
|
+
const defaultDb = basic.useDb()
|
|
378
|
+
const anotherRepo = basic.useDb({ repoId })
|
|
379
|
+
const mountedDb = basic.useDb({ mountId: openMountId })
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<p>
|
|
383
|
+
{defaultDb.kind}; {anotherRepo.source === 'default' ? 'default' : 'explicit'};
|
|
384
|
+
{'files' in mountedDb ? ' mounted' : ''}
|
|
385
|
+
</p>
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The `{ mountId }` overload exposes `MountViewerFiles`. To obtain editor-only mounted-file methods,
|
|
391
|
+
use the role-discriminated handle returned by `useMounts().open()`.
|
|
392
|
+
|
|
393
|
+
### `basic.useCollection()` and record lifecycle
|
|
394
|
+
|
|
395
|
+
`basic.useCollection(table, { source? })` returns a schema-typed `Collection<Value>`. The object is
|
|
396
|
+
an imperative API; use `useQuery` or `watch` when rendering data.
|
|
397
|
+
|
|
398
|
+
| Method | Signature | Behavior |
|
|
399
|
+
| --- | --- | --- |
|
|
400
|
+
| `create` | `create(value, { id?, idempotencyKey? }?)` | Create with an optional record ID |
|
|
401
|
+
| `put` | `put(id, value, { idempotencyKey? }?)` | Replace the full value; creates when missing |
|
|
402
|
+
| `patch` | `patch(id, partial, { unset?, idempotencyKey? }?)` | Shallow-set fields and optionally remove named fields |
|
|
403
|
+
| `delete` | `delete(id, { idempotencyKey? }?)` | Soft-delete; retains a recoverable value |
|
|
404
|
+
| `restore` | `restore(id, { idempotencyKey? }?)` | Restore a deleted record |
|
|
405
|
+
| `purge` | `purge(id, { idempotencyKey? }?)` | Destructively erase the value and retain a purged shell |
|
|
406
|
+
| `get` | `get(id)` | Return `BasicRecord<Value> \| null` |
|
|
407
|
+
| `list` | `list(query?)` | Return `{ data, nextCursor }`; lifecycle-aware overloads narrow the records |
|
|
408
|
+
| `watch` | `watch(query, listener)` | Publish an initial page and sync updates; returns an unsubscribe function |
|
|
409
|
+
|
|
410
|
+
Sync mutations use generated durable operation IDs. REST mutations generate UUID idempotency keys;
|
|
411
|
+
if a REST caller supplies one, it must also be a UUID. REST updates use cached ETag preconditions.
|
|
412
|
+
|
|
413
|
+
Every mutation resolves to `{ record, opId }`. A record is one of:
|
|
414
|
+
|
|
415
|
+
- live: `{ id, value: V, meta: { state: 'live', ... } }`
|
|
416
|
+
- deleted: `{ id, value: V, meta: { state: 'deleted', ... } }`
|
|
417
|
+
- purged: `{ id, value: null, meta: { state: 'purged', ... } }`
|
|
418
|
+
|
|
419
|
+
Sync metadata can also include `sync: 'confirmed' | 'pending' | 'conflict' | 'rejected'` and
|
|
420
|
+
`pendingOps: string[]`; REST metadata can include `etag`. Check `meta.state` before using a value
|
|
421
|
+
from an all-state query.
|
|
422
|
+
|
|
423
|
+
```tsx
|
|
424
|
+
// file: TodoActions.tsx
|
|
425
|
+
import { useState } from 'react'
|
|
426
|
+
import { basic } from './basic'
|
|
427
|
+
|
|
428
|
+
export function TodoActions({ id }: { id: string }) {
|
|
429
|
+
const todos = basic.useCollection('todos')
|
|
430
|
+
const [lastOperation, setLastOperation] = useState<string | null>(null)
|
|
431
|
+
|
|
432
|
+
async function complete() {
|
|
433
|
+
const result = await todos.patch(id, { done: true }, { unset: ['rank'] })
|
|
434
|
+
setLastOperation(result.opId)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function replace() {
|
|
438
|
+
await todos.put(id, { title: 'Replaced', done: false })
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return (
|
|
442
|
+
<div>
|
|
443
|
+
<button onClick={() => void complete()}>Complete</button>
|
|
444
|
+
<button onClick={() => void replace()}>Replace</button>
|
|
445
|
+
<button onClick={() => void todos.delete(id)}>Delete</button>
|
|
446
|
+
<button onClick={() => void todos.restore(id)}>Restore</button>
|
|
447
|
+
<button onClick={() => void todos.purge(id)}>Purge permanently</button>
|
|
448
|
+
{lastOperation && <small>Queued as {lastOperation}</small>}
|
|
449
|
+
</div>
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Do not write system collections such as `_files` through the generic collection API.
|
|
455
|
+
|
|
456
|
+
### `basic.useQuery()`
|
|
457
|
+
|
|
458
|
+
`basic.useQuery(table, query?, { source? })` returns:
|
|
459
|
+
|
|
460
|
+
```text
|
|
461
|
+
{
|
|
462
|
+
data: BasicRecord<Value>[]
|
|
463
|
+
isLoading: boolean
|
|
464
|
+
error: BasicError | null
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`query` supports:
|
|
469
|
+
|
|
470
|
+
| Option | Type | Notes |
|
|
471
|
+
| --- | --- | --- |
|
|
472
|
+
| `where` | `{ [field]: value \| operators }` | A bare value is equality. Operators are `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `like`, `ilike`, and `isNull`. |
|
|
473
|
+
| `sort` | `string \| string[]` | Prefix a field with `-` for descending order. `id`, `created_at`, `updated_at`, `deleted_at`, and `last_seq` are metadata fields; other names address the record value. |
|
|
474
|
+
| `limit` | `number` | Integer from 1 through 200 |
|
|
475
|
+
| `cursor` | `string` | REST pagination only; local sync queries throw `LOCAL_CURSOR_UNSUPPORTED` |
|
|
476
|
+
| `state` | `'live' \| 'deleted' \| 'all'` | Defaults to `'live'` |
|
|
477
|
+
|
|
478
|
+
`like` and `ilike` use `*` wildcards. Filters are ANDed. Ordering is deterministic: `id` is added
|
|
479
|
+
as a tie-breaker, and the default local order is `-created_at, id`.
|
|
480
|
+
|
|
481
|
+
```tsx
|
|
482
|
+
// file: TodoList.tsx
|
|
483
|
+
import { basic } from './basic'
|
|
484
|
+
|
|
485
|
+
export function TodoList() {
|
|
486
|
+
const { data, isLoading, error } = basic.useQuery('todos', {
|
|
487
|
+
where: {
|
|
488
|
+
done: { eq: false },
|
|
489
|
+
rank: { gte: 1, lte: 10 },
|
|
490
|
+
title: { ilike: '*basic*' },
|
|
491
|
+
},
|
|
492
|
+
sort: ['rank', '-created_at'],
|
|
493
|
+
limit: 50,
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
if (isLoading) return <p>Loading todos…</p>
|
|
497
|
+
if (error) return <p>Query failed: {error.code}</p>
|
|
498
|
+
return <ul>{data.map((todo) => <li key={todo.id}>{todo.value?.title}</li>)}</ul>
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Before the first ready client snapshot, `data` is `[]`, `isLoading` is `true`, and `error` is null.
|
|
503
|
+
When a query invalidates, the previous data remains available while `isLoading` becomes true and
|
|
504
|
+
the old error is cleared. Success replaces `data`; failure retains the previous data and supplies a
|
|
505
|
+
`BasicError` with `isLoading: false`.
|
|
506
|
+
|
|
507
|
+
In sync mode, the hook subscribes to the local collection and reruns after local or remote replica
|
|
508
|
+
changes. In REST mode there is no server subscription; the hook fetches its first page and reruns
|
|
509
|
+
after a successful collection mutation made through this client. Change the query/options or
|
|
510
|
+
remount to fetch other external changes.
|
|
511
|
+
|
|
512
|
+
### `basic.useSyncStatus()`
|
|
513
|
+
|
|
514
|
+
`useSyncStatus(source?)` returns:
|
|
515
|
+
|
|
516
|
+
- `status: BasicSyncStatus`: `'idle'`, `'bootstrapping'`, `'connecting'`, `'online'`, `'offline'`,
|
|
517
|
+
`'stopped'`, `'error'`, `'local'`, `'stale'`, or `'ended'`.
|
|
518
|
+
- `pendingCount: number`: queued operations for the selected source, or all open sources when no
|
|
519
|
+
source is supplied.
|
|
520
|
+
- `rejected: BasicRejection[]`: terminally rejected operations with `source`, `op`, `error`, and
|
|
521
|
+
optional `message`.
|
|
522
|
+
- `conflicts: BasicConflict[]`: unresolved operations with `source`, `op`, overlapping `fields`,
|
|
523
|
+
and a `structural` flag.
|
|
524
|
+
- `resolveConflict(opId, 'keep-mine' | 'keep-theirs'): Promise<boolean>`.
|
|
525
|
+
- `discardRejected(opId): Promise<boolean>`.
|
|
526
|
+
|
|
527
|
+
Connectivity controls and the confirmation barrier live on `basic.client`:
|
|
528
|
+
|
|
529
|
+
```tsx
|
|
530
|
+
// file: SyncControls.tsx
|
|
531
|
+
import { basic } from './basic'
|
|
532
|
+
|
|
533
|
+
export function SyncControls() {
|
|
534
|
+
const sync = basic.useSyncStatus()
|
|
535
|
+
|
|
536
|
+
async function waitForServer() {
|
|
537
|
+
await basic.client.settle({ timeoutMs: 10_000, allowConflicts: false })
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return (
|
|
541
|
+
<section>
|
|
542
|
+
<p>{sync.status}; {sync.pendingCount} pending</p>
|
|
543
|
+
<button onClick={() => basic.client.goOffline()}>Work offline</button>
|
|
544
|
+
<button onClick={() => basic.client.goOnline()}>Reconnect</button>
|
|
545
|
+
<button onClick={() => void waitForServer()}>Wait for sync</button>
|
|
546
|
+
{sync.conflicts.map((conflict) => (
|
|
547
|
+
<button
|
|
548
|
+
key={conflict.op.op_id}
|
|
549
|
+
onClick={() => void sync.resolveConflict(conflict.op.op_id, 'keep-mine')}
|
|
550
|
+
>
|
|
551
|
+
Keep my {conflict.op.record_id}
|
|
552
|
+
</button>
|
|
553
|
+
))}
|
|
554
|
+
{sync.rejected.map((rejection) => (
|
|
555
|
+
<button
|
|
556
|
+
key={rejection.op.op_id}
|
|
557
|
+
onClick={() => void sync.discardRejected(rejection.op.op_id)}
|
|
558
|
+
>
|
|
559
|
+
Dismiss {rejection.error}
|
|
560
|
+
</button>
|
|
561
|
+
))}
|
|
562
|
+
</section>
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
`client.settle({ timeoutMs?, allowConflicts?, cursors? })` waits for pending sync work and optional
|
|
568
|
+
per-subscription minimum numeric cursors. The default timeout is 5 seconds. It throws
|
|
569
|
+
`SETTLE_TIMEOUT` if work remains; in REST mode it is a no-op. `goOffline()` disconnects sync
|
|
570
|
+
sessions without rejecting local writes, and `goOnline()` reconnects them.
|
|
571
|
+
|
|
572
|
+
The default `conflictPolicy: 'keep-mine'` rebases and retries local intent. `'keep-theirs'` drops a
|
|
573
|
+
conflicting local operation. `'event'` leaves it in `conflicts` until `resolveConflict`; pass
|
|
574
|
+
`allowConflicts: true` only when those unresolved conflicts should not block `settle()`.
|
|
575
|
+
|
|
576
|
+
### `basic.useSchemaStatus()`
|
|
577
|
+
|
|
578
|
+
`useSchemaStatus(source?)` returns `{ mode, localVersion?, serverVersion?, drift }`:
|
|
579
|
+
|
|
580
|
+
- `mode` is `'basic-schema'`, `'dynamic'`, `'freeform'`, or `'unknown'`.
|
|
581
|
+
- `drift` is `'unknown'`, `'match'`, or `'different'`.
|
|
582
|
+
- version fields appear when the corresponding local/server schema is known.
|
|
583
|
+
|
|
584
|
+
The client checks after opening an account repo. Drift is informational and emits through `warn`;
|
|
585
|
+
it does not replace server-side schema validation.
|
|
586
|
+
|
|
587
|
+
```tsx
|
|
588
|
+
// file: SchemaNotice.tsx
|
|
589
|
+
import { basic } from './basic'
|
|
590
|
+
|
|
591
|
+
export function SchemaNotice() {
|
|
592
|
+
const schema = basic.useSchemaStatus()
|
|
593
|
+
if (schema.drift !== 'different') return null
|
|
594
|
+
return <p>App schema v{schema.localVersion} differs from repo schema v{schema.serverVersion}.</p>
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### `basic.useRepos()`
|
|
599
|
+
|
|
600
|
+
`useRepos()` returns:
|
|
601
|
+
|
|
602
|
+
- `repos: Repo[]` and `defaultRepoId: string | null` from the current catalog.
|
|
603
|
+
- `refresh(): Promise<Repo[]>` to reload active repos.
|
|
604
|
+
- `create({ name, schema_type?, schema? }): Promise<Repo>`. If `schema_type` is omitted, it defaults
|
|
605
|
+
to `'basic-schema'` when `schema` is supplied and `'freeform'` otherwise.
|
|
606
|
+
- `archive(repoId): Promise<void>` to archive a known repo using its current ETag.
|
|
607
|
+
|
|
608
|
+
`Repo` includes `id`, `name`, optional `kind`, `schema_type`, `schema_version`, `created_by`,
|
|
609
|
+
lifecycle timestamps, and `access: { is_default, permissions }`.
|
|
610
|
+
|
|
611
|
+
```tsx
|
|
612
|
+
// file: RepoPicker.tsx
|
|
613
|
+
import { basic } from './basic'
|
|
614
|
+
|
|
615
|
+
export function RepoPicker() {
|
|
616
|
+
const repos = basic.useRepos()
|
|
617
|
+
|
|
618
|
+
async function createNotesRepo() {
|
|
619
|
+
await repos.create({ name: 'Notes' })
|
|
620
|
+
await repos.refresh()
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
<section>
|
|
625
|
+
<p>Default: {repos.defaultRepoId ?? 'none'}</p>
|
|
626
|
+
<ul>{repos.repos.map((repo) => <li key={repo.id}>{repo.name}</li>)}</ul>
|
|
627
|
+
<button onClick={() => void createNotesRepo()}>Create repo</button>
|
|
628
|
+
</section>
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### `basic.useFiles()` and `basic.useStorageInfo()`
|
|
634
|
+
|
|
635
|
+
Files v2 is capability-gated. `useFiles(query?, { source? })` returns
|
|
636
|
+
`{ data, isLoading, error, refresh }`, where data is `Array<FileRecord | MountFileInfo>`. It
|
|
637
|
+
subscribes to `_files` changes in sync mode.
|
|
638
|
+
|
|
639
|
+
`FileListQuery` has `prefix?: string`, `state?: 'live' | 'deleted' | 'all'`, `limit?: number`, and
|
|
640
|
+
`cursor?: string`. A sync owner source does not accept a cursor. Mounted-file listing supports
|
|
641
|
+
`prefix`, `limit`, and `cursor`; mount sources do not apply the owner-only `state` filter.
|
|
642
|
+
|
|
643
|
+
`useStorageInfo()` returns `{ data: StorageInfo | null, isLoading, error, refresh }`. The hook loads
|
|
644
|
+
the signed-in default repo's usage using app credentials. `StorageInfo` contains `usedBytes`,
|
|
645
|
+
`quotaBytes`, `blobCount`, `maxUploadBytes`, and optional `repoId`, `fileCount`, and
|
|
646
|
+
`maxRecordsPerRepo`.
|
|
647
|
+
|
|
648
|
+
An owner `FileRecord` contains `id`, `state: 'live' | 'deleted' | 'purged'`, nullable `path`,
|
|
649
|
+
`name`, `size`, `sha256`, `contentType`, and `mtime`, plus optional sequence/timestamp/ETag
|
|
650
|
+
metadata. A `MountFileInfo` has the corresponding content fields, `state: 'live' | 'deleted'`, and
|
|
651
|
+
required `lastSeq`, `createdAt`, `updatedAt`, and nullable `deletedAt`.
|
|
652
|
+
|
|
653
|
+
```tsx
|
|
654
|
+
// file: FilesPanel.tsx
|
|
655
|
+
import { useEffect, useState } from 'react'
|
|
656
|
+
import { basic } from './basic'
|
|
657
|
+
|
|
658
|
+
export function FilesPanel() {
|
|
659
|
+
const listing = basic.useFiles({ prefix: '/receipts', state: 'live' })
|
|
660
|
+
const storage = basic.useStorageInfo()
|
|
661
|
+
const files = basic.useDb().files
|
|
662
|
+
const [progress, setProgress] = useState(0)
|
|
663
|
+
|
|
664
|
+
useEffect(() => files.watch({ prefix: '/receipts' }, () => listing.refresh()), [files])
|
|
665
|
+
|
|
666
|
+
async function upload(file: File) {
|
|
667
|
+
await files.upload({
|
|
668
|
+
file,
|
|
669
|
+
path: `/receipts/${file.name}`,
|
|
670
|
+
onProgress: (loaded, total) => setProgress(total === 0 ? 0 : loaded / total),
|
|
671
|
+
})
|
|
672
|
+
listing.refresh()
|
|
673
|
+
storage.refresh()
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function download(id: string) {
|
|
677
|
+
const grant = await files.download(id, { disposition: 'inline' })
|
|
678
|
+
const response = await grant.fetch()
|
|
679
|
+
const url = URL.createObjectURL(await response.blob())
|
|
680
|
+
window.open(url, '_blank', 'noopener,noreferrer')
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return (
|
|
684
|
+
<section>
|
|
685
|
+
<input
|
|
686
|
+
type="file"
|
|
687
|
+
onChange={(event) => {
|
|
688
|
+
const file = event.currentTarget.files?.[0]
|
|
689
|
+
if (file) void upload(file)
|
|
690
|
+
}}
|
|
691
|
+
/>
|
|
692
|
+
<progress value={progress} max={1} />
|
|
693
|
+
<p>{storage.data?.usedBytes ?? 0} / {storage.data?.quotaBytes ?? 0} bytes</p>
|
|
694
|
+
{listing.isLoading && <p>Loading files…</p>}
|
|
695
|
+
{listing.error && <p>{listing.error.code}</p>}
|
|
696
|
+
<ul>
|
|
697
|
+
{listing.data.map((file) => (
|
|
698
|
+
<li key={file.id}>
|
|
699
|
+
{file.path}
|
|
700
|
+
<button onClick={() => void download(file.id)}>Download</button>
|
|
701
|
+
<button onClick={() => void files.delete(file.id).then(listing.refresh)}>Delete</button>
|
|
702
|
+
</li>
|
|
703
|
+
))}
|
|
704
|
+
</ul>
|
|
705
|
+
</section>
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Owner files are available at `basic.useDb(source).files`:
|
|
711
|
+
|
|
712
|
+
| Method | Signature | Notes |
|
|
713
|
+
| --- | --- | --- |
|
|
714
|
+
| `upload` | `upload({ file, path, name?, mtime?, idempotencyKey?, signal?, onProgress? })` | Multipart upload with a shared four-transfer queue |
|
|
715
|
+
| `uploadBlob` | `uploadBlob(bytes, { signal? }?)` | Upload content bytes and return `{ sha256, size, deduplicated }` for a later content-reference update |
|
|
716
|
+
| `list` | `list({ prefix?, state?, limit?, cursor? }?)` | Returns `{ data: FileRecord[], nextCursor }` |
|
|
717
|
+
| `get` | `get(id)` | Returns a `FileRecord` or null |
|
|
718
|
+
| `download` | `download(id, { ttl?, disposition?, signal? }?)` | Returns a temporary grant with metadata and `fetch()`; TTL is capped to server capability |
|
|
719
|
+
| `update` | `update(id, metadataOrContent, { idempotencyKey?, ifMatch? }?)` | Patch `path`, `name`, `contentType`, `mtime`, `unset`, or replace content by `{ sha256, size }` |
|
|
720
|
+
| `delete` | `delete(id, options?)` | Recoverable soft deletion; does not reclaim blob storage |
|
|
721
|
+
| `restore` | `restore(id, options?)` | Restore a soft-deleted file |
|
|
722
|
+
| `purge` | `purge(id, options?)` | Destructive erasure path |
|
|
723
|
+
| `watch` | `watch(query, listener)` | Sync mode only; returns unsubscribe |
|
|
724
|
+
|
|
725
|
+
`FileRecord` uses camelCase metadata (`contentType`, `lastSeq`, `createdAt`, and so on) and nullable
|
|
726
|
+
content fields for purged shells. `download().fetch()` omits bearer credentials and obtains one
|
|
727
|
+
fresh grant if the provider returns 404. Upload and download retries honor supported server retry
|
|
728
|
+
signals; an `AbortSignal` cancels queue waiting and transfer work.
|
|
729
|
+
|
|
730
|
+
In REST mode there is no file subscription. Call the hook's `refresh()` after imperative file
|
|
731
|
+
mutations when the UI needs a new listing.
|
|
732
|
+
|
|
733
|
+
### `basic.useOutgoingShares()`
|
|
734
|
+
|
|
735
|
+
Shares v2 is capability-gated and requires sign-in. `useOutgoingShares()` returns:
|
|
736
|
+
|
|
737
|
+
```text
|
|
738
|
+
{
|
|
739
|
+
data: OutgoingShareInfo[]
|
|
740
|
+
outgoingShares: OutgoingShareInfo[] // alias of data
|
|
741
|
+
isLoading: boolean
|
|
742
|
+
error: BasicError | null
|
|
743
|
+
refresh(): void
|
|
744
|
+
create(input: CreateOutgoingShareInput): Promise<OutgoingShareInfo>
|
|
745
|
+
get(id: string): Promise<OutgoingShareInfo>
|
|
746
|
+
cancel(id: string): Promise<OutgoingShareInfo>
|
|
747
|
+
revoke(id: string): Promise<OutgoingShareInfo>
|
|
748
|
+
manageUrl(): string
|
|
749
|
+
}
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
`CreateOutgoingShareInput` has:
|
|
753
|
+
|
|
754
|
+
| Field | Type | Meaning |
|
|
755
|
+
| --- | --- | --- |
|
|
756
|
+
| `repo` | `'default' \| { repoId: string }` | Origin repo |
|
|
757
|
+
| recipient | Exactly one of `recipientHandle: string` or `recipientDid: string` | A handle is trimmed, lowercased, and resolved through `identityOrigin`; a canonical DID skips handle resolution. |
|
|
758
|
+
| `role` | `'viewer' \| 'editor'` | Collaborator role |
|
|
759
|
+
| `scope` | `Array<{ table: string } \| { table: string; recordIds: string[] }>` | Whole-table clauses or static record sets |
|
|
760
|
+
| `acceptBy` | `string` | Optional acceptance deadline passed to Shares v2 |
|
|
761
|
+
| `display` | `{ shareName?: string; repoName?: string }` | Optional recipient-facing labels |
|
|
762
|
+
|
|
763
|
+
```tsx
|
|
764
|
+
// file: OutgoingShares.tsx
|
|
765
|
+
import { basic } from './basic'
|
|
766
|
+
|
|
767
|
+
export function OutgoingShares() {
|
|
768
|
+
const shares = basic.useOutgoingShares()
|
|
769
|
+
|
|
770
|
+
async function shareTodos() {
|
|
771
|
+
await shares.create({
|
|
772
|
+
repo: 'default',
|
|
773
|
+
recipientHandle: 'bob.basic.id',
|
|
774
|
+
role: 'editor',
|
|
775
|
+
scope: [{ table: 'todos' }],
|
|
776
|
+
display: { shareName: 'Project todos' },
|
|
777
|
+
})
|
|
778
|
+
shares.refresh()
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
<section>
|
|
783
|
+
<button onClick={() => void shareTodos()}>Share todos</button>
|
|
784
|
+
<button onClick={() => location.assign(shares.manageUrl())}>Manage in Basic ID</button>
|
|
785
|
+
{shares.error && <p>{shares.error.code}</p>}
|
|
786
|
+
<ul>
|
|
787
|
+
{shares.outgoingShares.map((share) => (
|
|
788
|
+
<li key={share.id}>
|
|
789
|
+
{share.recipientDid}: {share.effectiveState}
|
|
790
|
+
{share.state === 'pending' && (
|
|
791
|
+
<button onClick={() => void shares.cancel(share.id).then(shares.refresh)}>Cancel</button>
|
|
792
|
+
)}
|
|
793
|
+
{share.state === 'active' && (
|
|
794
|
+
<button onClick={() => void shares.revoke(share.id).then(shares.refresh)}>Revoke</button>
|
|
795
|
+
)}
|
|
796
|
+
</li>
|
|
797
|
+
))}
|
|
798
|
+
</ul>
|
|
799
|
+
</section>
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
`OutgoingShareInfo` exposes canonical `recipientDid`, `repoId`, `appId`, `role`, `scope`,
|
|
805
|
+
`staticRecordCount`, `display`, `acceptBy`, `state: 'pending' | 'active' | 'ended'`,
|
|
806
|
+
`effectiveState: 'pending' | 'active' | 'suspended' | 'ended'`, `effectiveStateReason`,
|
|
807
|
+
`deliveryState`, and created, accepted, and ended timestamps/reasons. Revocation prevents future
|
|
808
|
+
access but cannot retract data a recipient already downloaded.
|
|
809
|
+
|
|
810
|
+
`manageUrl()` returns the Basic ID `/shares` handoff. Invite acceptance and ordinary mount
|
|
811
|
+
management belong there. `client.shares.leave()` is intentionally owner-only and throws
|
|
812
|
+
`OWNER_CREDENTIAL_REQUIRED` without an explicit owner adapter.
|
|
813
|
+
|
|
814
|
+
### `basic.useMounts()` and mounted data
|
|
815
|
+
|
|
816
|
+
`useMounts({ repo? }?)` returns:
|
|
817
|
+
|
|
818
|
+
```text
|
|
819
|
+
{
|
|
820
|
+
data: MountInfo[]
|
|
821
|
+
mounts: MountInfo[] // alias of data
|
|
822
|
+
isLoading: boolean
|
|
823
|
+
error: BasicError | null
|
|
824
|
+
refresh(): void
|
|
825
|
+
open(mountId: string): Promise<MountHandle<S>>
|
|
826
|
+
manageUrl(): string
|
|
827
|
+
}
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
The optional `repo` filter is `'default'` or `{ repoId }`. `open()` completes the share credential
|
|
831
|
+
chain and opens a sync or REST mounted database. Open a mount before passing `{ mountId }` to
|
|
832
|
+
`useDb`, `useCollection`, `useQuery`, or `useFiles`.
|
|
833
|
+
|
|
834
|
+
Each `MountInfo` contains `id`, `invitationId`, `originOwnerDid`, `originShareId`, `appId`, `repoId`,
|
|
835
|
+
`role`, `scope`, `state: 'active' | 'left'`,
|
|
836
|
+
`originState: 'active' | 'suspended' | 'ended' | 'unavailable' | 'unknown'`,
|
|
837
|
+
`originCheckedAt`, `createdAt`, and `leftAt`.
|
|
838
|
+
|
|
839
|
+
`MountHandle<S>` is discriminated by `role`:
|
|
840
|
+
|
|
841
|
+
- A viewer handle has `info`, `db`, and `files` with `list`, `get`, and `download`.
|
|
842
|
+
- An editor handle adds file `update`, `delete`, and `restore`.
|
|
843
|
+
- Collaborators never receive mounted-file `upload`, `uploadBlob`, content replacement, creation,
|
|
844
|
+
or `purge` methods.
|
|
845
|
+
|
|
846
|
+
```tsx
|
|
847
|
+
// file: IncomingMounts.tsx
|
|
848
|
+
import { useState } from 'react'
|
|
849
|
+
import type { MountHandle } from '@basictech/core'
|
|
850
|
+
import { basic, schema } from './basic'
|
|
851
|
+
|
|
852
|
+
export function IncomingMounts() {
|
|
853
|
+
const mounts = basic.useMounts()
|
|
854
|
+
const [opened, setOpened] = useState<MountHandle<typeof schema> | null>(null)
|
|
855
|
+
|
|
856
|
+
async function open(mountId: string) {
|
|
857
|
+
const handle = await mounts.open(mountId)
|
|
858
|
+
setOpened(handle)
|
|
859
|
+
const page = await handle.files.list({ prefix: '/shared' })
|
|
860
|
+
if (handle.role === 'editor' && page.data[0]) {
|
|
861
|
+
await handle.files.update(page.data[0].id, { name: 'Reviewed' })
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return (
|
|
866
|
+
<section>
|
|
867
|
+
<button onClick={() => location.assign(mounts.manageUrl())}>Manage mounts</button>
|
|
868
|
+
<ul>
|
|
869
|
+
{mounts.mounts.map((mount) => (
|
|
870
|
+
<li key={mount.id}>
|
|
871
|
+
{mount.role} from {mount.originOwnerDid}
|
|
872
|
+
<button onClick={() => void open(mount.id)}>Open</button>
|
|
873
|
+
</li>
|
|
874
|
+
))}
|
|
875
|
+
</ul>
|
|
876
|
+
{opened && <p>Opened {opened.info.id} as {opened.role}</p>}
|
|
877
|
+
</section>
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
Once opened, records use the same collection/query surface:
|
|
883
|
+
|
|
884
|
+
```tsx
|
|
885
|
+
// file: MountedTodos.tsx
|
|
886
|
+
import { basic } from './basic'
|
|
887
|
+
|
|
888
|
+
export function MountedTodos({ mountId }: { mountId: string }) {
|
|
889
|
+
const result = basic.useQuery('todos', { sort: 'title' }, { source: { mountId } })
|
|
890
|
+
if (result.isLoading) return <p>Opening shared todos…</p>
|
|
891
|
+
if (result.error) return <p>{result.error.code}</p>
|
|
892
|
+
return <ul>{result.data.map((todo) => <li key={todo.id}>{todo.value?.title}</li>)}</ul>
|
|
893
|
+
}
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
The record collection shape is the same for viewer and editor mounts; the server enforces role and
|
|
897
|
+
scope on attempted record mutations. Mounted-file types are narrower at compile time as described
|
|
898
|
+
above. Terminal ended mounts are removed from the open runtime and their local partition is
|
|
899
|
+
deleted; transient origin outages are not treated as revocation.
|
|
900
|
+
|
|
901
|
+
### `basic.useBasic()` and `basic.client`
|
|
902
|
+
|
|
903
|
+
`useBasic()` combines the common state:
|
|
904
|
+
|
|
905
|
+
```text
|
|
906
|
+
UseAuthResult & {
|
|
907
|
+
client: BasicClient<S>
|
|
908
|
+
db: BasicDb<S>
|
|
909
|
+
accounts: UseAccountsResult
|
|
910
|
+
sync: UseSyncStatusResult
|
|
911
|
+
repos: Repo[]
|
|
912
|
+
}
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
The auth fields (`isReady`, `isSignedIn`, `signIn`, and so on) are at the top level. `accounts` and
|
|
916
|
+
`sync` are the complete corresponding hook results; `repos` is only the repo array, so use
|
|
917
|
+
`useRepos()` for refresh/create/archive actions.
|
|
918
|
+
|
|
919
|
+
```tsx
|
|
920
|
+
// file: BasicSummary.tsx
|
|
921
|
+
import { basic } from './basic'
|
|
922
|
+
|
|
923
|
+
export function BasicSummary() {
|
|
924
|
+
const state = basic.useBasic()
|
|
925
|
+
return (
|
|
926
|
+
<p>
|
|
927
|
+
{state.isAnonymous ? 'anonymous' : state.handle};
|
|
928
|
+
{' '}{state.sync.pendingCount} pending;
|
|
929
|
+
{' '}{state.accounts.accounts.length} profiles;
|
|
930
|
+
{' '}{state.repos.length} repos;
|
|
931
|
+
{' '}{state.db.kind} mode
|
|
932
|
+
</p>
|
|
933
|
+
)
|
|
934
|
+
}
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
There is no separate `useClient()` hook. The stable client is `basic.client`, and `useBasic()` also
|
|
938
|
+
returns it. Use the client for imperative APIs that are not hook actions, including `settle`,
|
|
939
|
+
`goOffline`, `goOnline`, account-wide owner operations, and lower-level `shares` methods. Provider
|
|
940
|
+
normally owns `client.start()`/`stop()`.
|
|
941
|
+
|
|
942
|
+
## Unbound hooks and `BasicProvider`
|
|
943
|
+
|
|
944
|
+
Use the bound `createBasic` API for a fixed application schema. Use the generic exports when table
|
|
945
|
+
names are dynamic, a schema is not known at compile time, or a client is composed outside the
|
|
946
|
+
factory.
|
|
947
|
+
|
|
948
|
+
`BasicProvider` accepts `children`, optional `renderWhileLoading`, and exactly one client source:
|
|
949
|
+
|
|
950
|
+
- `{ client: BasicClient<S> }`, or
|
|
951
|
+
- the complete `BrowserBasicConfig<S>` as props (with `client` absent).
|
|
952
|
+
|
|
953
|
+
When config props are supplied, the Provider creates one browser client on its first render;
|
|
954
|
+
subsequent prop changes do not recreate it. Creating the client explicitly is clearer when other
|
|
955
|
+
code also needs it.
|
|
956
|
+
|
|
957
|
+
```tsx
|
|
958
|
+
// file: GenericProvider.tsx
|
|
959
|
+
import {
|
|
960
|
+
BasicProvider,
|
|
961
|
+
createBasicClient,
|
|
962
|
+
useCollection,
|
|
963
|
+
useQuery,
|
|
964
|
+
} from '@basictech/react'
|
|
965
|
+
|
|
966
|
+
type Note = { body: string; pinned?: boolean }
|
|
967
|
+
|
|
968
|
+
const client = createBasicClient({
|
|
969
|
+
clientId: 'did:web:dynamic.example',
|
|
970
|
+
redirectUri: 'https://dynamic.example/oauth/callback',
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
function Notes() {
|
|
974
|
+
const notes = useCollection<Note>('notes')
|
|
975
|
+
const result = useQuery<Note>('notes', { where: { pinned: true } })
|
|
976
|
+
return (
|
|
977
|
+
<section>
|
|
978
|
+
<button onClick={() => void notes.create({ body: 'Untitled' })}>Add note</button>
|
|
979
|
+
{result.data.map((note) => <p key={note.id}>{note.value?.body}</p>)}
|
|
980
|
+
</section>
|
|
981
|
+
)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export function GenericRoot() {
|
|
985
|
+
return <BasicProvider client={client}><Notes /></BasicProvider>
|
|
986
|
+
}
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
The unbound exports mirror the bound hooks:
|
|
990
|
+
|
|
991
|
+
| Export | Generic behavior |
|
|
992
|
+
| --- | --- |
|
|
993
|
+
| `useBasic<S>()` | Optional explicit schema generic; otherwise `BasicSchema` |
|
|
994
|
+
| `useAuth()` | Same auth result |
|
|
995
|
+
| `useAccounts()` | Same account result |
|
|
996
|
+
| `useDb<S>(source?)` | Optional explicit schema generic; mount overload keeps mounted-file restrictions |
|
|
997
|
+
| `useCollection<V>(name, { source? }?)` | Caller supplies a JSON object value type; name is a string |
|
|
998
|
+
| `useQuery<V>(name, query?, { source? }?)` | Caller supplies a JSON object value type; name is a string |
|
|
999
|
+
| `useSyncStatus(source?)`, `useSchemaStatus(source?)` | Same status results |
|
|
1000
|
+
| `useRepos()`, `useFiles()`, `useStorageInfo()` | Same repo/file results |
|
|
1001
|
+
| `useMounts()`, `useOutgoingShares()` | Same share results |
|
|
1002
|
+
|
|
1003
|
+
Every unbound hook must run below `BasicProvider`; otherwise it throws `Basic hooks must be used
|
|
1004
|
+
within a <BasicProvider>`.
|
|
1005
|
+
|
|
1006
|
+
## Error handling
|
|
1007
|
+
|
|
1008
|
+
Import `BasicError`, `ProblemError`, and `OAuthError` from `@basictech/core`, not from the React
|
|
1009
|
+
package. `BasicError` has `code: string` and a message. `ProblemError` adds HTTP `status`, normalized
|
|
1010
|
+
Problem Details, and response metadata; `OAuthError` adds the OAuth `error`, optional description,
|
|
1011
|
+
and status.
|
|
1012
|
+
|
|
1013
|
+
```ts
|
|
1014
|
+
// file: errors.ts
|
|
1015
|
+
import { BasicError, ProblemError } from '@basictech/core'
|
|
1016
|
+
import { basic } from './basic'
|
|
1017
|
+
|
|
1018
|
+
export async function renameTodo(id: string) {
|
|
1019
|
+
try {
|
|
1020
|
+
await basic.client.db().collection('todos').patch(id, { title: 'Renamed' })
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
if (error instanceof ProblemError) {
|
|
1023
|
+
console.error(error.code, error.status, error.problem.request_id)
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
if (error instanceof BasicError) {
|
|
1027
|
+
console.error(error.code, error.message)
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
throw error
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
Common application-facing codes include:
|
|
1036
|
+
|
|
1037
|
+
| Code | Typical cause |
|
|
1038
|
+
| --- | --- |
|
|
1039
|
+
| `AUTHORIZATION_REQUIRED` | A signed-in token/session is required |
|
|
1040
|
+
| `UNKNOWN_SOURCE` | The repo is not in the catalog or the mount has not been listed/opened |
|
|
1041
|
+
| `RECORD_NOT_FOUND`, `RECORD_EXISTS`, `RECORD_NOT_DELETED` | Record lifecycle precondition failed |
|
|
1042
|
+
| `INVALID_PATCH`, `INVALID_RECORD_ID`, `INVALID_IDEMPOTENCY_KEY` | Invalid local mutation input |
|
|
1043
|
+
| `INVALID_QUERY_*`, `LOCAL_CURSOR_UNSUPPORTED` | Invalid query, limit/sort/filter, or a REST cursor used locally |
|
|
1044
|
+
| `WATCH_REQUIRES_SYNC_MODE`, `SYNC_MODE_REQUIRED` | A sync-only API was called in REST mode |
|
|
1045
|
+
| `SETTLE_TIMEOUT` | Pending sync work did not settle before the configured timeout |
|
|
1046
|
+
| `CAPABILITY_UNAVAILABLE` | The PDS does not advertise Files v2 |
|
|
1047
|
+
| `SHARES_UNSUPPORTED`, `SHARE_ENDED`, `SHARE_SCOPE_DENIED`, `SHARE_ROLE_DENIED` | Shares v2 unavailable or a mount/share lifecycle/permission denied the operation |
|
|
1048
|
+
| `OWNER_CREDENTIAL_REQUIRED` | An owner-only operation was called with normal app credentials |
|
|
1049
|
+
| `STALE_WRITE`, `SCHEMA_VALIDATION_FAILED` | Server rejected a write; REST errors arrive as `ProblemError`, while sync terminal failures appear in `rejected` |
|
|
1050
|
+
| `ACCESS_TOKEN_ADOPTION_TIMEOUT`, `invalid_grant` | Cross-tab token adoption failed or reauthentication is required |
|
|
1051
|
+
|
|
1052
|
+
Data hooks (`useQuery`, `useFiles`, `useStorageInfo`, `useMounts`, and `useOutgoingShares`) use the
|
|
1053
|
+
same `{ data, isLoading, error }` lifecycle. Files/mounts/outgoing also expose `refresh()`. Disabled
|
|
1054
|
+
signed-out storage, mount, and outgoing-share hooks settle after client readiness with empty/null
|
|
1055
|
+
data rather than remaining loading. Non-Basic data-hook failures are wrapped as `UNKNOWN_ERROR`;
|
|
1056
|
+
query failures use `QUERY_FAILED` as the fallback code. `useAuth().error` reflects auth lifecycle
|
|
1057
|
+
errors; imperative methods reject and should be caught at the call site.
|
|
1058
|
+
|
|
1059
|
+
## Sync and REST modes
|
|
1060
|
+
|
|
1061
|
+
The database and query shapes are shared, but the guarantees differ:
|
|
1062
|
+
|
|
1063
|
+
| | `mode: 'sync'` (default) | `mode: 'rest'` |
|
|
1064
|
+
| --- | --- | --- |
|
|
1065
|
+
| Reads | Local replica after bootstrap | Direct HTTP |
|
|
1066
|
+
| Mutations resolve | After the optimistic operation is durably enqueued | After the server response |
|
|
1067
|
+
| Offline writes | Persisted and replayed after reconnect | Not queued |
|
|
1068
|
+
| React updates | Local writes and incoming Sync/3 changes invalidate queries | Initial fetch and same-client collection mutation invalidation; no server subscription |
|
|
1069
|
+
| `watch()` | Supported for records/files | Throws `WATCH_REQUIRES_SYNC_MODE` |
|
|
1070
|
+
| Anonymous workspace | Enabled by default | Disabled |
|
|
1071
|
+
| Multi-account profiles | Supported; active profile is per tab | Not supported |
|
|
1072
|
+
| `settle()` | Waits for server confirmation | No-op |
|
|
1073
|
+
| Write concurrency | Sync/3 rebase/conflict policy | ETag/`If-Match`; stale writes surface as `ProblemError` |
|
|
1074
|
+
| Pagination cursor | Local queries do not accept cursors | Returns/accepts REST cursors |
|
|
1075
|
+
|
|
1076
|
+
Sync mode persists the operation before publishing its optimistic view. `settle()` is the explicit
|
|
1077
|
+
barrier when a workflow requires server confirmation. Reconnect drains queued operations; retryable
|
|
1078
|
+
failures remain pending, terminal failures move to `rejected`, and stale operations are rebased up
|
|
1079
|
+
to the core limit before becoming conflicts/rejections. A conflict policy chooses automatic local
|
|
1080
|
+
or remote intent, or leaves the choice to the app.
|
|
1081
|
+
|
|
1082
|
+
REST and sync/mount cursors are engine-specific; do not exchange them. REST does not silently
|
|
1083
|
+
rebase an ETag failure. See the
|
|
1084
|
+
[0.11 migration guide](https://github.com/basicdb/basic-server/blob/dev/docs/MIGRATING_TO_0.11.md)
|
|
1085
|
+
and the
|
|
1086
|
+
[`@basictech/core` README](https://github.com/basicdb/basic-server/blob/dev/packages/core/README.md)
|
|
1087
|
+
for the lower-level model.
|
|
1088
|
+
|
|
1089
|
+
## Next.js, migration, and status
|
|
1090
|
+
|
|
1091
|
+
- `@basictech/nextjs` is lockstep-versioned but **compile-only and functionally stale in 0.11**.
|
|
1092
|
+
Its old cookie middleware is not a valid authentication boundary for this browser client. New
|
|
1093
|
+
Client Components should import `@basictech/react` directly.
|
|
1094
|
+
- Migrating from 0.10 requires deliberate call-site changes; there is no compatibility layer. Read
|
|
1095
|
+
[Migrating to Basic SDK 0.11](https://github.com/basicdb/basic-server/blob/dev/docs/MIGRATING_TO_0.11.md).
|
|
1096
|
+
- `0.11.0-beta.0` is a beta API. Check the installed version rather than assuming the repository
|
|
1097
|
+
workspace has already been published.
|
|
1098
|
+
|
|
1099
|
+
## License
|
|
1100
|
+
|
|
1101
|
+
MIT
|