@basictech/react 0.9.0-beta.1 → 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/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