@basictech/react 0.9.0-beta.1 → 0.11.0-beta.2

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