@basictech/react 0.8.0-beta.4 → 0.9.0-beta.0

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/dist/index.d.ts CHANGED
@@ -1,395 +1,1115 @@
1
- import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import react from 'react';
3
- export { useLiveQuery as useQuery } from 'dexie-react-hooks';
1
+ import React from 'react';
2
+ import Dexie, { Table } from 'dexie';
3
+ import { useLiveQuery } from 'dexie-react-hooks';
4
4
 
5
+ interface BasicStorage {
6
+ get(key: string): Promise<string | null>;
7
+ set(key: string, value: string): Promise<void>;
8
+ remove(key: string): Promise<void>;
9
+ }
10
+ declare class LocalStorageAdapter implements BasicStorage {
11
+ get(key: string): Promise<string | null>;
12
+ set(key: string, value: string): Promise<void>;
13
+ remove(key: string): Promise<void>;
14
+ }
15
+ declare const STORAGE_KEYS: {
16
+ readonly REFRESH_TOKEN: "basic_refresh_token";
17
+ readonly USER_INFO: "basic_user_info";
18
+ readonly AUTH_STATE: "basic_auth_state";
19
+ readonly REDIRECT_URI: "basic_redirect_uri";
20
+ readonly SERVER_URL: "basic_server_url";
21
+ readonly PDS_ENDPOINTS: "basic_pds_endpoints";
22
+ readonly LAST_CONNECT_REPORT: "basic_last_connect_report";
23
+ readonly DEBUG: "basic_debug";
24
+ readonly CODE_VERIFIER: "basic_code_verifier";
25
+ };
26
+
27
+ type Token = {
28
+ access_token: string;
29
+ token_type: string;
30
+ expires_in: number;
31
+ refresh_token: string;
32
+ };
33
+ type User = {
34
+ sub?: string;
35
+ name?: string;
36
+ email?: string;
37
+ picture?: string;
38
+ };
5
39
  /**
6
- * Core DB types for Basic SDK
7
- * These interfaces are implemented by both SyncDB (Dexie-based) and RemoteDB (REST-based)
40
+ * High-level auth lifecycle state.
41
+ *
42
+ * - `bootstrapping` — SDK is initializing; not yet determined if a session exists.
43
+ * - `authenticated` — User has a valid access token and active session.
44
+ * - `recovering` — A session likely exists (refresh token / cached user) but
45
+ * the SDK hasn't confirmed it yet (e.g. offline, mid-refresh).
46
+ * - `reauth_required` — The session is definitively invalid (revoked, expired
47
+ * refresh token, etc.). The user must sign in again.
48
+ * NOTE: `isSignedIn` remains `true` in this state so the UI
49
+ * can display user info while prompting re-authentication.
50
+ * Use `authStatus === 'reauth_required'` to distinguish
51
+ * this from a healthy signed-in state.
52
+ * - `signed_out` — No session. User is not authenticated.
53
+ *
54
+ * TODO: revisit naming and ergonomics — consider adding a `needsReauth` or
55
+ * `shouldPromptSignIn` convenience getter so consumers don't need to inspect
56
+ * the raw status to decide between "sign in" vs "sign out" UI.
8
57
  */
58
+ type AuthStatus = 'bootstrapping' | 'authenticated' | 'recovering' | 'reauth_required' | 'signed_out';
59
+ type AuthResult = {
60
+ success: boolean;
61
+ error?: string;
62
+ code?: string;
63
+ };
64
+ type GetTokenOptions$1 = {
65
+ forceRefresh?: boolean;
66
+ };
67
+ type PdsEndpoints = {
68
+ pds_url: string;
69
+ authorization_endpoint: string;
70
+ token_endpoint: string;
71
+ userinfo_endpoint: string;
72
+ };
73
+ type AuthManagerConfig = {
74
+ projectId: string | undefined;
75
+ scopes: string;
76
+ pdsUrl: string;
77
+ adminUrl: string;
78
+ debug: boolean;
79
+ };
9
80
  /**
10
- * Collection interface for CRUD operations on a table
11
- * All write operations return the full object (not just the id)
81
+ * Framework-agnostic auth manager. Holds token state, handles OAuth flow,
82
+ * token refresh (with mutex), and user info fetching.
83
+ *
84
+ * React integration: pass a state-setter as `notify` so the component
85
+ * re-renders whenever auth state changes.
12
86
  */
13
- interface Collection<T extends {
14
- id: string;
15
- } = Record<string, any> & {
16
- id: string;
17
- }> {
87
+ declare class AuthManager {
88
+ token: Token | null;
89
+ user: User | null;
90
+ isSignedIn: boolean;
91
+ isAuthReady: boolean;
92
+ authStatus: AuthStatus;
93
+ authErrorCode: string | null;
94
+ did: string | null;
95
+ /** Space-separated scopes granted in the current access token */
96
+ tokenScope: string | null;
97
+ /** Space-separated scopes originally requested in the auth config */
98
+ requestedScopes: string;
99
+ readonly config: AuthManagerConfig;
100
+ readonly storage: BasicStorage;
101
+ /** True only during a user-initiated OAuth code exchange (not session restore) */
102
+ private freshSignIn;
103
+ private notify;
104
+ private refreshPromise;
105
+ private codeExchangePromise;
106
+ private pendingRefresh;
107
+ private isOnline;
108
+ private channel;
109
+ private nextUserRecoveryAt;
110
+ private sessionCheckPromise;
111
+ private lastSessionCheckAt;
112
+ constructor(config: AuthManagerConfig, storage: BasicStorage, notify: () => void);
113
+ private initCrossTabSync;
114
+ private broadcastTokenRefresh;
115
+ private broadcastSignIn;
116
+ private broadcastSignOut;
117
+ private broadcastSessionInvalidated;
18
118
  /**
19
- * Add a new record to the collection
20
- * @param data - The data to add (without id, which will be generated)
21
- * @returns The created object with its generated id
119
+ * Bootstrap auth: handle OAuth callback (?code=), restore session
120
+ * from refresh token, or load cached user for offline mode.
22
121
  */
23
- add(data: Omit<T, 'id'>): Promise<T>;
122
+ initialize(): Promise<void>;
24
123
  /**
25
- * Put (upsert) a record - requires id
26
- * @param data - The full object including id
27
- * @returns The upserted object
124
+ * Get a valid access token string. Refreshes proactively (5s buffer)
125
+ * or on demand (forceRefresh). Mutex prevents concurrent refreshes.
28
126
  */
29
- put(data: T): Promise<T>;
127
+ getToken(options?: GetTokenOptions$1): Promise<string>;
128
+ getSignInUrl(redirectUri?: string, endpoints?: PdsEndpoints): Promise<string>;
129
+ signIn(redirectUri?: string): Promise<void>;
130
+ signInWithHandle(handle: string): Promise<void>;
131
+ signInWithCode(code: string, state?: string): Promise<AuthResult>;
30
132
  /**
31
- * Update an existing record by id
32
- * @param id - The record id to update
33
- * @param data - Partial data to merge
34
- * @returns The updated object, or null if not found
133
+ * Sign out: revoke the session server-side (`POST /auth/logout`, best
134
+ * effort), then clear auth state and storage. Does NOT handle sync/DB
135
+ * cleanup the client layer wraps this to add sync teardown.
35
136
  */
36
- update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null>;
137
+ signOut(): Promise<void>;
37
138
  /**
38
- * Delete a record by id
39
- * @param id - The record id to delete
40
- * @returns true if deleted, false if not found
139
+ * Best-effort server-side revocation of the current device/session and
140
+ * its refresh chain (Step 2 auth: logout is finally server-side).
141
+ * Never blocks or fails the local sign-out.
41
142
  */
42
- delete(id: string): Promise<boolean>;
143
+ private revokeSessionOnServer;
144
+ reconcileSession(reason?: string, options?: {
145
+ forceRefresh?: boolean;
146
+ throttleMs?: number;
147
+ }): Promise<void>;
148
+ hasScope(scope: string): boolean;
43
149
  /**
44
- * Get a single record by id
45
- * @param id - The record id to fetch
46
- * @returns The object or null if not found
150
+ * Returns scopes that were requested but not granted in the current token.
151
+ * Useful after login or when a 403 is returned.
47
152
  */
48
- get(id: string): Promise<T | null>;
153
+ missingScopes(): string[];
49
154
  /**
50
- * Get all records in the collection
51
- * @returns Array of all objects
155
+ * Register online/offline and visibility handlers that retry pending
156
+ * refreshes and proactively refresh tokens when the app resumes from
157
+ * background (critical for PWAs and mobile browsers where timers are
158
+ * frozen while backgrounded).
159
+ * Returns a cleanup function for useEffect teardown.
52
160
  */
53
- getAll(): Promise<T[]>;
161
+ setupNetworkListeners(): () => void;
162
+ private get adminHostname();
163
+ private defaultPdsEndpoints;
164
+ private getActivePdsEndpoints;
165
+ private reportConnection;
54
166
  /**
55
- * Filter records using a predicate function
56
- * @param fn - Filter function that returns true for matches
57
- * @returns Array of matching objects
167
+ * After a new token is stored, decode JWT claims and fetch user info.
58
168
  */
59
- filter(fn: (item: T) => boolean): Promise<T[]>;
169
+ private processNewToken;
170
+ private restoreCachedUser;
171
+ private fetchUser;
60
172
  /**
61
- * Direct access to underlying storage (optional)
62
- * For sync mode: Dexie table reference
63
- * For remote mode: undefined
173
+ * Exchange an auth code or refresh token for an access token.
174
+ * Handles mutex (one in-flight refresh), token validation, and
175
+ * triggers processNewToken on success.
64
176
  */
65
- ref?: any;
66
- }
67
- /**
68
- * BasicDB interface - factory for creating collections
69
- */
70
- interface BasicDB {
177
+ private exchangeToken;
178
+ private resetAuthState;
179
+ private clearStoredAuth;
180
+ private isNetworkError;
181
+ private getRefreshToken;
182
+ private syncRefreshTokenFromStorage;
183
+ private applyTokenClaims;
184
+ private broadcastSessionUpdate;
185
+ private handleUserFetchFailure;
186
+ private isCompatibleUser;
187
+ private recoverMissingUserProfile;
188
+ private isDefinitiveTokenErrorCode;
189
+ private isDefinitiveAuthFailure;
71
190
  /**
72
- * Get a collection by name
73
- * @param name - The table/collection name (must match schema)
74
- * @returns A Collection instance for CRUD operations
191
+ * Centralised auth status setter. Derives `isSignedIn` and `isAuthReady`
192
+ * from the status so they stay consistent.
193
+ *
194
+ * `isSignedIn` is intentionally `true` during `reauth_required` so the
195
+ * UI layer can still display user info while prompting re-authentication.
196
+ * Consumers should check `authStatus` (or a future convenience getter)
197
+ * when they need to distinguish "healthy session" from "needs re-auth".
75
198
  */
76
- collection<T extends {
77
- id: string;
78
- } = Record<string, any> & {
79
- id: string;
80
- }>(name: string): Collection<T>;
199
+ private updateAuthStatus;
200
+ private clearStoredSessionTokens;
201
+ private restoreStoredSession;
202
+ private handleExternalTokenRefresh;
203
+ private fetchCurrentSession;
204
+ private markReauthRequired;
81
205
  }
206
+
82
207
  /**
83
- * Database mode - determines which implementation is used
84
- * - 'sync': Uses Dexie + WebSocket for local-first sync (default)
85
- * - 'remote': Uses REST API calls directly to server
208
+ * Sync/2 wire protocol types the client side of basic-server's
209
+ * `docs/SYNC_V2.md` contract (protocol version 1).
210
+ *
211
+ * WebSocket endpoint: `wss://<pds>/sync/`
212
+ * HTTP bootstrap: `GET /account/:project_id/db/snapshot`
213
+ * HTTP pull: `GET /account/:project_id/db/changes`
86
214
  */
87
- type DBMode = 'sync' | 'remote';
215
+ declare const PROTOCOL_VERSION = 1;
216
+ type OpType = 'put' | 'patch' | 'delete';
217
+ /** An op as pushed by the client. `op_id` is the client-minted idempotency key. */
218
+ interface OpEnvelope {
219
+ op_id: string;
220
+ type: OpType;
221
+ table: string;
222
+ record_id: string;
223
+ data?: Record<string, unknown> | null;
224
+ /** What the writer had seen when creating the op. Recorded, never enforced (v1). */
225
+ base_seq?: number | null;
226
+ }
227
+ /** An op as delivered by the server (`ops` message / `changes` endpoint). */
228
+ interface LoggedOp extends OpEnvelope {
229
+ seq: number;
230
+ /** Server-stamped writer identity — never sent by the client. */
231
+ actor: string;
232
+ server_ts: string | number;
233
+ }
234
+ type SyncErrorCode = 'SCHEMA_VALIDATION_FAILED' | 'UNKNOWN_TABLE' | 'RECORD_NOT_FOUND' | 'PERMISSION_DENIED' | 'PAYLOAD_TOO_LARGE' | 'CHANNEL_FULL' | 'TOO_MANY_OPS' | 'RATE_LIMITED' | 'TRY_AGAIN' | 'SNAPSHOT_REQUIRED' | 'RESET_REQUIRED' | 'UNAUTHORIZED' | 'TOKEN_EXPIRED' | 'SHARE_REVOKED' | 'CONNECTION_REVOKED' | 'UNSUPPORTED_VERSION' | 'BAD_MESSAGE';
235
+ /** Terminal = poison-op rule applies: park in rejected store, never re-push. */
236
+ declare function isTerminalOpError(code: string | undefined, terminalFlag?: boolean): boolean;
237
+ /** Errors that mean "discard channel state, re-bootstrap from snapshot, resubscribe". */
238
+ declare function isRebootstrapError(code: string): boolean;
239
+ /** Errors that mean the subscription is gone and must not be retried. */
240
+ declare function isRevocationError(code: string): boolean;
241
+ /** Errors that mean the token was rejected; reauth then reconnect. */
242
+ declare function isAuthError(code: string): boolean;
243
+ interface SyncLimits {
244
+ max_ops_per_push: number;
245
+ max_op_bytes: number;
246
+ replay_limit: number;
247
+ }
248
+ declare const DEFAULT_LIMITS: SyncLimits;
88
249
  /**
89
- * Auth error information passed to onAuthError callback
250
+ * First message on the socket. Sending only `{version, token}` opens a
251
+ * multiplexed session (auth-only welcome); streams are then bound with
252
+ * explicit `subscribe` messages. (The legacy single-subscription handshake
253
+ * with `cursor`/`share` on the hello is not used by this client.)
90
254
  */
91
- interface AuthError {
92
- status: number;
93
- message: string;
94
- response?: any;
95
- /** Classifies the error for UI display (e.g. "session expired" vs "forbidden") */
96
- errorType: 'expired' | 'forbidden' | 'revoked' | 'network' | 'unknown';
97
- /** True if this error occurred after a retry with a refreshed token */
98
- afterRetry: boolean;
255
+ interface HelloMsg {
256
+ type: 'hello';
257
+ version: number;
258
+ token: string;
259
+ }
260
+ interface SubscribeFilter {
261
+ table: string;
262
+ record_ids?: string[];
263
+ }
264
+ interface SubscribeMsg {
265
+ type: 'subscribe';
266
+ sub: string;
267
+ /** Last seq seen. Required — a null/absent cursor gets SNAPSHOT_REQUIRED. */
268
+ cursor: number;
269
+ /** Mount a share instead of the token's own channel. */
270
+ share?: string;
271
+ /** Narrow an own-channel subscription (partial sync). */
272
+ filter?: SubscribeFilter;
273
+ }
274
+ interface UnsubscribeMsg {
275
+ type: 'unsubscribe';
276
+ sub: string;
277
+ }
278
+ interface PushMsg {
279
+ type: 'push';
280
+ sub: string;
281
+ ops: OpEnvelope[];
282
+ }
283
+ interface PingMsg {
284
+ type: 'ping';
285
+ cursor?: number;
286
+ }
287
+ /** Refresh auth on a live socket without reconnecting. */
288
+ interface TokenMsg {
289
+ type: 'token';
290
+ token: string;
291
+ }
292
+ type ClientMsg = HelloMsg | SubscribeMsg | UnsubscribeMsg | PushMsg | PingMsg | TokenMsg;
293
+ /** Multiplexed handshake: auth-only welcome (`channel`/`cursor` absent). */
294
+ interface WelcomeMsg {
295
+ type: 'welcome';
296
+ actor: string;
297
+ limits: SyncLimits;
298
+ channel?: string;
299
+ cursor?: number;
300
+ head?: number;
301
+ schema_version?: string | number | null;
302
+ }
303
+ interface SubscribedMsg {
304
+ type: 'subscribed';
305
+ sub: string;
306
+ channel: string;
307
+ cursor: number;
308
+ head: number;
309
+ schema_version: string | number | null;
310
+ }
311
+ interface UnsubscribedMsg {
312
+ type: 'unsubscribed';
313
+ sub: string;
314
+ }
315
+ interface OpsMsg {
316
+ type: 'ops';
317
+ sub: string;
318
+ /** May be empty — a filtered tail still advances `cursor`; always adopt it. */
319
+ ops: LoggedOp[];
320
+ cursor: number;
321
+ }
322
+ type PushResult = {
323
+ op_id: string;
324
+ seq: number;
325
+ } | {
326
+ op_id: string;
327
+ error: SyncErrorCode;
328
+ terminal?: boolean;
329
+ message?: string;
330
+ };
331
+ interface PushedMsg {
332
+ type: 'pushed';
333
+ sub: string;
334
+ results: PushResult[];
335
+ /** Channel head after commit. Absent when nothing was committed. */
336
+ cursor?: number;
337
+ }
338
+ interface PongMsg {
339
+ type: 'pong';
340
+ head: number;
341
+ heads?: Record<string, number>;
342
+ }
343
+ interface TokenOkMsg {
344
+ type: 'token_ok';
99
345
  }
346
+ interface ErrorMsg {
347
+ type: 'error';
348
+ code: SyncErrorCode;
349
+ message?: string;
350
+ terminal?: boolean;
351
+ /** Present when the error concerns one subscription on a multiplexed socket. */
352
+ sub?: string;
353
+ }
354
+ type ServerMsg = WelcomeMsg | SubscribedMsg | UnsubscribedMsg | OpsMsg | PushedMsg | PongMsg | TokenOkMsg | ErrorMsg;
355
+ /** `GET /account/:project_id/db/snapshot` → `{ data: Snapshot }` */
356
+ interface Snapshot {
357
+ channel: string;
358
+ /** table → record_id → field values (tombstones excluded, no `id` field). */
359
+ records: Record<string, Record<string, Record<string, unknown>>>;
360
+ /** Head seq the snapshot is consistent with. */
361
+ cursor: number;
362
+ }
363
+ /** `GET /account/:project_id/db/changes` → `{ data: ChangesPage }` */
364
+ interface ChangesPage {
365
+ ops: LoggedOp[];
366
+ cursor: number;
367
+ more: boolean;
368
+ }
369
+ type SharePermission = 'read' | 'write';
370
+ interface ShareSelector {
371
+ table: string;
372
+ record_ids?: string[] | null;
373
+ }
374
+ /** A share row as returned by `GET /account/shares`. */
375
+ interface Share {
376
+ id: string;
377
+ account_id: string;
378
+ channel_id: string;
379
+ grantee: string;
380
+ selector: ShareSelector;
381
+ permission: SharePermission;
382
+ created_at: string;
383
+ expires_at: string | null;
384
+ revoked_at: string | null;
385
+ }
386
+ /** Client-minted op id (idempotency key). */
387
+ declare function mintOpId(): string;
100
388
  /**
101
- * Custom error class for Remote DB API errors
102
- * Includes HTTP status code for reliable error handling
389
+ * Apply one op to a record's field data. Returns the new data, or `undefined`
390
+ * when the record does not exist (deleted / never created).
391
+ *
392
+ * - `put`: create or replace the whole record
393
+ * - `patch`: shallow-merge top-level fields; no effect on a missing record
394
+ * - `delete`: tombstone (idempotent)
103
395
  */
104
- declare class RemoteDBError extends Error {
105
- status: number;
106
- response?: any;
107
- constructor(message: string, status: number, response?: any);
108
- }
396
+ declare function applyOpToData(existing: Record<string, unknown> | undefined, op: OpEnvelope): Record<string, unknown> | undefined;
397
+
109
398
  /**
110
- * Options for getToken (e.g. force refresh after 401)
399
+ * RestClient the REST v2 surface under `/account/:project_id/db/...`.
400
+ *
401
+ * Used by the sync engine for bootstrap (`snapshot`) and pull sync
402
+ * (`changes`), by the shares API, and by the REST-mode table API (CRUD on
403
+ * materialized state). All writes go through the same server-side sync
404
+ * engine as WebSocket pushes — one write path.
111
405
  */
112
- interface GetTokenOptions$1 {
113
- /** When true, refresh the access token before returning (e.g. after server returned 401) */
406
+
407
+ interface GetTokenOptions {
114
408
  forceRefresh?: boolean;
115
409
  }
116
- /**
117
- * Configuration for RemoteDB
118
- */
119
- interface RemoteDBConfig {
120
- serverUrl: string;
410
+ declare class RestError extends Error {
411
+ readonly status: number;
412
+ readonly code?: string;
413
+ readonly response?: unknown;
414
+ constructor(message: string, status: number, code?: string, response?: unknown);
415
+ }
416
+ declare class NotAuthenticatedError extends Error {
417
+ constructor(message?: string);
418
+ }
419
+ interface RestClientOptions {
420
+ /** PDS base URL, e.g. `https://pds.basic.id` */
421
+ baseUrl: string;
422
+ /** Project id (UUID or DID) — used in `/account/:project_id/...` paths. */
121
423
  projectId: string;
122
- getToken: (options?: GetTokenOptions$1) => Promise<string>;
123
- schema?: any;
124
- /** Enable debug logging (default: false) */
125
- debug?: boolean;
126
- /**
127
- * Optional callback when authentication fails (401 error after retry)
128
- * Use this to show login UI or redirect to sign-in
129
- */
130
- onAuthError?: (error: AuthError) => void;
424
+ getToken: (options?: GetTokenOptions) => Promise<string>;
425
+ log?: (...args: unknown[]) => void;
131
426
  }
132
-
133
- /**
134
- * RemoteDB - REST API based implementation of BasicDB
135
- * Creates RemoteCollection instances for each table
136
- */
137
- declare class RemoteDB implements BasicDB {
138
- private config;
139
- private collections;
140
- constructor(config: RemoteDBConfig);
427
+ /** A record as returned by REST reads: flattened `{ id, ...fields }`. */
428
+ type RestRecord = {
429
+ id: string;
430
+ } & Record<string, unknown>;
431
+ declare class RestClient {
432
+ private readonly opts;
433
+ constructor(opts: RestClientOptions);
434
+ get projectId(): string;
435
+ /** `GET /account/:project_id/db` — tables, enforced schema version, channel head. */
436
+ getDbInfo(): Promise<{
437
+ tables: string[];
438
+ schema_version: string | number | null;
439
+ channel: {
440
+ id: string;
441
+ head_seq: number;
442
+ compacted_to_seq: number;
443
+ };
444
+ }>;
445
+ /** Bootstrap snapshot (SPEC §5). `share` bootstraps a mount; `table` filters. */
446
+ getSnapshot(options?: {
447
+ share?: string;
448
+ table?: string;
449
+ }): Promise<Snapshot>;
450
+ /** Pull ordered ops after a cursor — the non-WebSocket sync path. */
451
+ getChanges(options: {
452
+ cursor: number;
453
+ limit?: number;
454
+ share?: string;
455
+ table?: string;
456
+ }): Promise<ChangesPage>;
141
457
  /**
142
- * Get a collection by name
143
- * Collections are cached for reuse
458
+ * Shares granted by and received by the caller. App tokens see only
459
+ * shares involving their own app (the ones they can mount).
144
460
  */
145
- collection<T extends {
146
- id: string;
147
- } = Record<string, any> & {
148
- id: string;
149
- }>(name: string): Collection<T>;
461
+ listShares(): Promise<{
462
+ granted: Share[];
463
+ received: Share[];
464
+ }>;
465
+ list(table: string, query?: Record<string, string>): Promise<RestRecord[]>;
466
+ getRecord(table: string, id: string): Promise<RestRecord | null>;
467
+ /** `POST` — server mints the record id. */
468
+ createRecord(table: string, value: Record<string, unknown>): Promise<RestRecord>;
469
+ /** `PUT` — full replace. REST semantics: 404 for missing records. */
470
+ putRecord(table: string, id: string, value: Record<string, unknown>): Promise<RestRecord | null>;
471
+ /** `PATCH` — partial merge. 404 → null. */
472
+ patchRecord(table: string, id: string, value: Record<string, unknown>): Promise<RestRecord | null>;
473
+ /** `DELETE`. Returns false when the record did not exist. */
474
+ deleteRecord(table: string, id: string): Promise<boolean>;
475
+ private get dbPath();
476
+ /** Authenticated request; retries once with a force-refreshed token on 401. */
477
+ private request;
150
478
  }
151
479
 
152
480
  /**
153
- * Error thrown when user is not authenticated
481
+ * SyncStore persistent local state for one sync keyspace (an own channel or
482
+ * a mounted share), backed by plain Dexie/IndexedDB.
483
+ *
484
+ * Layout (one Dexie database per keyspace):
485
+ * - one **view** store per app table: `{ id, ...fields }` — what the app
486
+ * reads (server state + pending ops rebased). `useQuery`/liveQuery reads
487
+ * these stores directly.
488
+ * - `_server`: confirmed records only, keyed `[table+record_id]`.
489
+ * - `_pending`: queued local ops (survive restarts), insertion-ordered.
490
+ * - `_rejected`: terminally rejected ops (poison-op rule — never re-pushed).
491
+ * - `_meta`: cursor, channel id, limits, schema version.
492
+ *
493
+ * The app-visible view is always `serverState + pending applied in order`
494
+ * (client responsibility #6). Rebasing is done per affected record inside
495
+ * the same transaction as the change that triggered it.
154
496
  */
155
- declare class NotAuthenticatedError extends Error {
156
- constructor(message?: string);
497
+
498
+ interface PendingRow {
499
+ /** Auto-increment — preserves creation order across restarts. */
500
+ idx?: number;
501
+ op_id: string;
502
+ op: OpEnvelope;
503
+ /** Set when the server acked the op (`pushed` seq) but the echo hasn't arrived yet. */
504
+ acked_seq?: number;
157
505
  }
158
- /**
159
- * RemoteCollection - REST API based implementation of the Collection interface
160
- * All operations make HTTP calls to the Basic API server
161
- */
162
- declare class RemoteCollection<T extends {
163
- id: string;
164
- } = Record<string, any> & {
165
- id: string;
166
- }> implements Collection<T> {
167
- private tableName;
168
- private config;
169
- constructor(tableName: string, config: RemoteDBConfig);
170
- private log;
506
+ interface RejectedRow {
507
+ idx?: number;
508
+ op_id: string;
509
+ op: OpEnvelope;
510
+ error: SyncErrorCode | string;
511
+ message?: string;
512
+ rejected_at: number;
513
+ }
514
+ interface SyncStoreSchema {
515
+ version?: number;
516
+ tables: Record<string, {
517
+ fields: Record<string, {
518
+ indexed?: boolean;
519
+ }>;
520
+ }>;
521
+ }
522
+ declare class SyncStore {
523
+ readonly db: Dexie;
524
+ readonly name: string;
525
+ private readonly tableNames;
526
+ constructor(name: string, schema: SyncStoreSchema);
527
+ /** The Dexie view table for an app table (what liveQuery reads). */
528
+ view(table: string): Table<Record<string, unknown>, string>;
529
+ hasTable(table: string): boolean;
530
+ get tables(): string[];
531
+ private get server();
532
+ private get pending();
533
+ private get rejected();
534
+ private get meta();
535
+ private get allStores();
536
+ getCursor(): Promise<number | null>;
537
+ getChannel(): Promise<string | null>;
538
+ /** All pending ops in creation order (used to warm the in-memory queue). */
539
+ loadPending(): Promise<PendingRow[]>;
540
+ listRejected(): Promise<RejectedRow[]>;
541
+ clearRejected(): Promise<void>;
542
+ /** Record a server ack for a pending op (echo not yet seen). */
543
+ markAcked(opId: string, seq: number): Promise<void>;
171
544
  /**
172
- * Check if an error is a "not authenticated" error
545
+ * Enqueue a local op and apply it optimistically to the view.
546
+ * Returns the resulting view record (null when the op deletes it).
173
547
  */
174
- private isNotAuthenticatedError;
548
+ addPending(op: OpEnvelope): Promise<Record<string, unknown> | null>;
175
549
  /**
176
- * Helper to make authenticated API requests
177
- * Automatically retries once on 401 (token expired) by refreshing the token
550
+ * Commit a batch of incoming server ops (already filtered/deduped by the
551
+ * engine): update `_server`, drop confirmed pending ops, advance the
552
+ * cursor, and rebase every affected view record — in one transaction.
178
553
  */
179
- private request;
554
+ commitIncoming(params: {
555
+ applyOps: LoggedOp[];
556
+ confirmedOpIds: string[];
557
+ cursor: number;
558
+ }): Promise<void>;
559
+ /** Persist a cursor advance with no ops (empty `ops` message / pushed cursor). */
560
+ setCursor(cursor: number): Promise<void>;
180
561
  /**
181
- * Validate data against schema if available
562
+ * Terminal rejection: remove from pending, park in the rejected store,
563
+ * roll the view record back to server state + remaining pending ops.
182
564
  */
183
- private validateData;
565
+ rejectPending(opId: string, error: SyncErrorCode | string, message?: string): Promise<RejectedRow | null>;
184
566
  /**
185
- * Get the base path for this collection
567
+ * Replace all server state from a snapshot (cold start or
568
+ * SNAPSHOT_REQUIRED/RESET_REQUIRED recovery). Pending ops survive and are
569
+ * re-applied on top of the fresh state.
186
570
  */
187
- private get basePath();
571
+ replaceFromSnapshot(params: {
572
+ channel: string;
573
+ records: Record<string, Record<string, Record<string, unknown>>>;
574
+ cursor: number;
575
+ }): Promise<void>;
576
+ getViewRecord(table: string, id: string): Promise<Record<string, unknown> | null>;
577
+ getViewRecords(table: string): Promise<Record<string, unknown>[]>;
578
+ close(): void;
579
+ /** Delete the underlying IndexedDB database (sign-out / revoked mount). */
580
+ destroy(): Promise<void>;
581
+ private applyToServer;
188
582
  /**
189
- * Add a new record to the collection
190
- * The server generates the ID
191
- * Requires authentication - throws NotAuthenticatedError if not signed in
583
+ * Rebase one record: view = server data + pending ops for that record in
584
+ * creation order. Must run inside a transaction covering all stores.
192
585
  */
193
- add(data: Omit<T, 'id'>): Promise<T>;
194
- /**
195
- * Put (upsert) a record - requires id
196
- * Requires authentication - throws NotAuthenticatedError if not signed in
197
- */
198
- put(data: T): Promise<T>;
586
+ private recomputeViewRecord;
587
+ }
588
+
589
+ type SyncStatus = 'idle' | 'connecting' | 'online' | 'offline' | 'auth_required' | 'revoked' | 'stopped';
590
+ declare const OWN_SUB = "own";
591
+ declare function shareSubKey(shareId: string): string;
592
+ interface PendingEntry {
593
+ op: OpEnvelope;
594
+ sent: boolean;
595
+ ackedSeq?: number;
596
+ }
597
+ type SubStatus = 'initializing' | 'live' | 'revoked' | 'error';
598
+ declare class BoundedSet {
599
+ private readonly cap;
600
+ private set;
601
+ private order;
602
+ constructor(cap?: number);
603
+ has(value: string): boolean;
604
+ add(value: string): void;
605
+ clear(): void;
606
+ }
607
+ interface SubscriptionState {
608
+ key: string;
609
+ shareId: string | null;
610
+ store: SyncStore;
611
+ cursor: number;
612
+ pending: PendingEntry[];
613
+ /** Bound on the current socket (subscribed ack received). */
614
+ active: boolean;
615
+ bootstrapped: boolean;
616
+ status: SubStatus;
617
+ revokedCode?: string;
618
+ appliedOpIds: BoundedSet;
619
+ /** Serializes ops/pushed/bootstrap handling per subscription. */
620
+ chain: Promise<void>;
621
+ schemaVersion: string | number | null;
622
+ }
623
+ interface SyncEngineEvents {
624
+ status: SyncStatus;
625
+ /** Records changed in a subscription's view (local write or server ops). */
626
+ change: {
627
+ sub: string;
628
+ tables: string[];
629
+ };
630
+ rejected: {
631
+ sub: string;
632
+ rejection: RejectedRow;
633
+ };
634
+ /** Sub-level protocol errors (share revoked, re-bootstrapping, ...). */
635
+ suberror: {
636
+ sub: string;
637
+ code: string;
638
+ message?: string;
639
+ };
640
+ /** Connection-level revocation — the app connection is gone. */
641
+ revoked: {
642
+ code: string;
643
+ message?: string;
644
+ };
645
+ }
646
+ interface EngineSchema {
647
+ project_id?: string;
648
+ version?: number;
649
+ tables: Record<string, {
650
+ fields: Record<string, {
651
+ type: string;
652
+ required?: boolean;
653
+ indexed?: boolean;
654
+ }>;
655
+ }>;
656
+ }
657
+ interface SyncEngineOptions {
658
+ projectId: string;
659
+ schema: EngineSchema;
660
+ /** e.g. `wss://pds.basic.id/sync/` */
661
+ wsUrl: string;
662
+ getToken: (options?: {
663
+ forceRefresh?: boolean;
664
+ }) => Promise<string>;
665
+ /** Bootstrap fetch (REST snapshot). Injected so the engine stays transport-thin. */
666
+ fetchSnapshot: (options?: {
667
+ share?: string;
668
+ }) => Promise<Snapshot>;
669
+ /** Prefix for IndexedDB database names. Default `basic-sync`. */
670
+ dbNamePrefix?: string;
199
671
  /**
200
- * Update an existing record by id
201
- * Requires authentication - throws NotAuthenticatedError if not signed in
672
+ * App name included in `subscribe` messages. Production derives the channel
673
+ * from the token and ignores this; the sync-playground conformance server
674
+ * requires it. Leave unset against production.
202
675
  */
203
- update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null>;
676
+ appName?: string;
677
+ WebSocketImpl?: typeof WebSocket;
678
+ heartbeatMs?: number;
679
+ /** Validate writes locally before queueing (mirrors server). Default true. */
680
+ validateWrites?: boolean;
681
+ log?: (...args: unknown[]) => void;
682
+ }
683
+ declare class SyncEngine {
684
+ readonly projectId: string;
685
+ readonly schema: EngineSchema;
686
+ private readonly opts;
687
+ private readonly connection;
688
+ private readonly subs;
689
+ private limits;
690
+ private actor;
691
+ private started;
692
+ private revokedInfo;
693
+ private connectionStatus;
694
+ private _status;
695
+ private listeners;
696
+ private timers;
697
+ constructor(opts: SyncEngineOptions);
698
+ on<E extends keyof SyncEngineEvents>(event: E, fn: (data: SyncEngineEvents[E]) => void): () => void;
699
+ private emit;
700
+ get status(): SyncStatus;
701
+ get syncLimits(): SyncLimits;
702
+ get serverActor(): string | null;
703
+ getSubscription(key: string): SubscriptionState | undefined;
704
+ get own(): SubscriptionState | undefined;
705
+ get pendingCount(): number;
706
+ listRejected(subKey?: string): Promise<RejectedRow[]>;
707
+ clearRejected(subKey?: string): Promise<void>;
708
+ /** Open the own-channel keyspace and connect. Idempotent. */
709
+ start(): Promise<void>;
710
+ /** Close the socket and stores; local data is kept. */
711
+ stop(): void;
204
712
  /**
205
- * Delete a record by id
206
- * Requires authentication - throws NotAuthenticatedError if not signed in
713
+ * Stop and delete every local database for this project (sign-out).
714
+ * Best-effort discovery of mount keyspaces from previous sessions.
207
715
  */
208
- delete(id: string): Promise<boolean>;
716
+ destroyLocal(): Promise<void>;
209
717
  /**
210
- * Get a single record by id
211
- * Returns null if not authenticated (graceful degradation for read operations)
718
+ * Mount a share: separate keyspace `(project, share)` with its own cursor
719
+ * and pending queue. Bootstraps + subscribes when the socket is online.
212
720
  */
213
- get(id: string): Promise<T | null>;
721
+ mountShare(shareId: string): Promise<SubscriptionState>;
722
+ /** Unsubscribe a mount. Local cache is kept unless `purge` is set. */
723
+ unmountShare(shareId: string, options?: {
724
+ purge?: boolean;
725
+ }): Promise<void>;
214
726
  /**
215
- * Get all records in the collection
216
- * Returns empty array if not authenticated (graceful degradation for read operations)
727
+ * Queue a local op, apply it optimistically, and push when online.
728
+ * Returns the resulting view record (null when deleted).
729
+ * Throws on local validation failure (fail fast — the server would
730
+ * terminally reject it anyway).
217
731
  */
218
- getAll(): Promise<T[]>;
732
+ apply(subKey: string, partial: {
733
+ type: OpType;
734
+ table: string;
735
+ record_id: string;
736
+ data?: Record<string, unknown>;
737
+ }): Promise<Record<string, unknown> | null>;
738
+ private handleConnectionStatus;
739
+ private handleWelcome;
740
+ private handleMessage;
741
+ private handleSubscribed;
742
+ private handleError;
743
+ private openSub;
744
+ /** Bootstrap if needed, then bind the stream on the current socket. */
745
+ private activateSub;
746
+ /** Cold start = snapshot + tail; never log replay (§5). Pending survives. */
747
+ private bootstrapSub;
219
748
  /**
220
- * Filter records using a predicate function
221
- * Note: This fetches all records and filters client-side
222
- * Returns empty array if not authenticated (graceful degradation for read operations)
749
+ * Responsibilities 3+4+5: ordered apply, cursor advance, dedupe/confirm.
750
+ * Runs inside the sub's serial chain.
223
751
  */
224
- filter(fn: (item: T) => boolean): Promise<T[]>;
752
+ private processOps;
225
753
  /**
226
- * ref is not available for remote collections
754
+ * Push verdicts (§6.5, §8, §9). Success acks are recorded but the op stays
755
+ * pending until its echo arrives in seq order — this preserves strict
756
+ * ordered apply even when `pushed` races ahead of intermediate remote ops.
757
+ * Terminal errors apply the poison-op rule; retryables back off.
227
758
  */
228
- ref: undefined;
759
+ private processPushed;
760
+ /** Push unsent pending ops, chunked to `limits.max_ops_per_push`. */
761
+ private flush;
762
+ private get dbPrefix();
763
+ private enqueue;
764
+ private timer;
765
+ private recomputeStatus;
766
+ private log;
229
767
  }
230
768
 
231
- interface BasicStorage {
232
- get(key: string): Promise<string | null>;
233
- set(key: string, value: string): Promise<void>;
234
- remove(key: string): Promise<void>;
769
+ /**
770
+ * The table API — the app-facing database surface, shaped around Sync/2 ops.
771
+ *
772
+ * Two implementations of the same interface:
773
+ * - `SyncDb` — offline-first over a SyncEngine subscription (own channel or
774
+ * a mounted share). Writes queue ops and apply optimistically; reads hit
775
+ * the local Dexie view stores (which `useQuery`/liveQuery observe).
776
+ * - `RestDb` — direct REST calls, no local persistence (for server-ish
777
+ * contexts or apps that don't want a local replica).
778
+ */
779
+
780
+ type BasicRecord = {
781
+ id: string;
782
+ } & Record<string, unknown>;
783
+ interface BasicTable<T extends BasicRecord = BasicRecord> {
784
+ /** Create a record. Sync mode mints the id locally; REST mode server-side. */
785
+ create(data: Omit<T, 'id'>): Promise<T>;
786
+ /** Create or replace the whole record (Sync/2 `put` op). */
787
+ put(id: string, data: Omit<T, 'id'>): Promise<T>;
788
+ /** Shallow-merge top-level fields (`patch` op). Returns null when missing. */
789
+ patch(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null>;
790
+ /** Delete (tombstone). Idempotent. */
791
+ delete(id: string): Promise<void>;
792
+ get(id: string): Promise<T | null>;
793
+ getAll(): Promise<T[]>;
794
+ find(predicate: (record: T) => boolean): Promise<T[]>;
795
+ /** Sync mode: the Dexie view table (for liveQuery / advanced queries). */
796
+ ref?: Table<T, string>;
235
797
  }
236
- declare class LocalStorageAdapter implements BasicStorage {
237
- get(key: string): Promise<string | null>;
238
- set(key: string, value: string): Promise<void>;
239
- remove(key: string): Promise<void>;
798
+ interface BasicDb {
799
+ readonly kind: 'sync' | 'rest';
800
+ table<T extends BasicRecord = BasicRecord>(name: string): BasicTable<T>;
801
+ }
802
+ declare class SyncDb implements BasicDb {
803
+ private readonly engine;
804
+ private readonly subKey;
805
+ readonly kind: "sync";
806
+ private readonly tables;
807
+ constructor(engine: SyncEngine, subKey?: string);
808
+ table<T extends BasicRecord = BasicRecord>(name: string): BasicTable<T>;
809
+ }
810
+ declare class RestDb implements BasicDb {
811
+ private readonly rest;
812
+ private readonly schema?;
813
+ readonly kind: "rest";
814
+ private readonly tables;
815
+ constructor(rest: RestClient, schema?: {
816
+ tables?: Record<string, unknown>;
817
+ } | undefined);
818
+ table<T extends BasicRecord = BasicRecord>(name: string): BasicTable<T>;
240
819
  }
241
- declare const STORAGE_KEYS: {
242
- readonly REFRESH_TOKEN: "basic_refresh_token";
243
- readonly USER_INFO: "basic_user_info";
244
- readonly AUTH_STATE: "basic_auth_state";
245
- readonly REDIRECT_URI: "basic_redirect_uri";
246
- readonly SERVER_URL: "basic_server_url";
247
- readonly PDS_ENDPOINTS: "basic_pds_endpoints";
248
- readonly LAST_CONNECT_REPORT: "basic_last_connect_report";
249
- readonly DEBUG: "basic_debug";
250
- readonly CODE_VERIFIER: "basic_code_verifier";
251
- };
252
820
 
253
- type User = {
254
- sub?: string;
255
- name?: string;
256
- email?: string;
257
- picture?: string;
258
- };
259
821
  /**
260
- * High-level auth lifecycle state.
261
- *
262
- * - `bootstrapping` SDK is initializing; not yet determined if a session exists.
263
- * - `authenticated` User has a valid access token and active session.
264
- * - `recovering` — A session likely exists (refresh token / cached user) but
265
- * the SDK hasn't confirmed it yet (e.g. offline, mid-refresh).
266
- * - `reauth_required` — The session is definitively invalid (revoked, expired
267
- * refresh token, etc.). The user must sign in again.
268
- * NOTE: `isSignedIn` remains `true` in this state so the UI
269
- * can display user info while prompting re-authentication.
270
- * Use `authStatus === 'reauth_required'` to distinguish
271
- * this from a healthy signed-in state.
272
- * - `signed_out` — No session. User is not authenticated.
822
+ * BasicClient — the framework-agnostic SDK core. Owns:
823
+ * - AuthManager (OAuth/PKCE, tokens, cross-tab session)
824
+ * - SyncEngine (Sync/2 client: local store, pending queue, WS connection)
825
+ * - RestClient (REST v2: snapshot/changes bootstrap, shares, CRUD)
273
826
  *
274
- * TODO: revisit naming and ergonomics consider adding a `needsReauth` or
275
- * `shouldPromptSignIn` convenience getter so consumers don't need to inspect
276
- * the raw status to decide between "sign in" vs "sign out" UI.
827
+ * and orchestrates them: sync connects when a healthy session exists, stops
828
+ * on reauth_required, and tears down local data on sign-out (no page reload).
829
+ * The React layer (`BasicProvider` + hooks) is a thin subscriber.
277
830
  */
278
- type AuthStatus = 'bootstrapping' | 'authenticated' | 'recovering' | 'reauth_required' | 'signed_out';
279
- type AuthResult = {
280
- success: boolean;
281
- error?: string;
282
- code?: string;
283
- };
284
- type GetTokenOptions = {
285
- forceRefresh?: boolean;
286
- };
287
831
 
288
- declare enum DBStatus {
289
- LOADING = "LOADING",
290
- OFFLINE = "OFFLINE",
291
- CONNECTING = "CONNECTING",
292
- ONLINE = "ONLINE",
293
- SYNCING = "SYNCING",
294
- ERROR = "ERROR",
295
- /** Sync-layer error with automatic retry (maps from dexie-syncable status 4). */
296
- ERROR_WILL_RETRY = "ERROR_WILL_RETRY",
297
- /**
298
- * Auth-driven status: set by the provider when `authStatus` transitions to
299
- * `reauth_required`, causing sync to disconnect. Unlike the other statuses
300
- * this is NOT mapped from a dexie-syncable status code — it is set
301
- * programmatically by `BasicProvider` to signal that the token is
302
- * definitively invalid and the user must re-authenticate before sync can
303
- * resume.
304
- */
305
- ERROR_TOKEN_EXPIRED = "ERROR_TOKEN_EXPIRED"
832
+ type BasicMode = 'sync' | 'rest';
833
+ interface BasicAuthConfig {
834
+ scopes?: string | string[];
835
+ /** PDS URL for auth, data, and sync (default https://pds.basic.id) */
836
+ pds_url?: string;
837
+ /** Admin server URL for connect reporting + schema status (default https://api.basic.tech) */
838
+ admin_url?: string;
839
+ /** Sync WebSocket URL. Default: pds_url with ws(s) scheme + `/sync/`. */
840
+ sync_url?: string;
841
+ }
842
+ interface BasicClientConfig {
843
+ /** The Basic schema document (`{ project_id, version, tables }`). */
844
+ schema?: Record<string, unknown> & {
845
+ project_id?: string;
846
+ version?: number;
847
+ tables?: Record<string, unknown>;
848
+ };
849
+ /** Project id override; normally taken from `schema.project_id`. */
850
+ project_id?: string;
851
+ auth?: BasicAuthConfig;
852
+ storage?: BasicStorage;
853
+ debug?: boolean;
854
+ /** 'sync' (offline-first local replica, default) or 'rest' (direct API). */
855
+ mode?: BasicMode;
856
+ /** Node/testing: pass the `ws` constructor. */
857
+ WebSocketImpl?: typeof WebSocket;
306
858
  }
307
- /** Snapshot of local schema vs server (for dev toolbar and debugging). */
308
- type BasicSchemaDevInfo = {
859
+ /** Local schema vs server status (dev toolbar and debugging). */
860
+ interface BasicSchemaDevInfo {
309
861
  projectId: string | null;
310
862
  localVersion: number | undefined;
311
863
  status: string;
312
864
  valid: boolean;
313
865
  lastCheckedAt: number;
314
866
  error?: string;
315
- };
316
- /**
317
- * Context type for useBasic hook
318
- */
319
- type BasicContextType = {
867
+ }
868
+ interface BasicClientSnapshot {
869
+ /** Auth bootstrap finished and (in sync mode) the local db is decided. */
320
870
  isReady: boolean;
321
871
  isSignedIn: boolean;
322
872
  authStatus: AuthStatus;
323
873
  authErrorCode: string | null;
324
874
  user: User | null;
325
875
  did: string | null;
876
+ /** Space-separated scopes granted in the current access token. */
326
877
  scope: string | null;
327
- hasScope: (scope: string) => boolean;
328
- missingScopes: () => string[];
329
- signIn: () => Promise<void>;
330
- signInWithHandle: (handle: string) => Promise<void>;
331
- signOut: () => Promise<void>;
332
- signInWithCode: (code: string, state?: string) => Promise<AuthResult>;
333
- getToken: (options?: GetTokenOptions) => Promise<string>;
334
- getSignInUrl: (redirectUri?: string) => Promise<string>;
335
- db: BasicDB;
336
- dbStatus: DBStatus;
337
- dbMode: DBMode;
338
- /** Local schema vs server status; null if no schema on the provider. */
878
+ syncStatus: SyncStatus;
879
+ pendingCount: number;
880
+ /** True when the schema allows sync (valid + published + matching server). */
881
+ syncEnabled: boolean;
339
882
  devInfo: BasicSchemaDevInfo | null;
340
- /** Re-run remote schema check (dev toolbar). */
341
- refreshSchemaStatus: () => Promise<void>;
342
- isAuthReady: boolean;
343
- signin: () => Promise<void>;
344
- signout: () => Promise<void>;
345
- signinWithCode: (code: string, state?: string) => Promise<AuthResult>;
346
- getSignInLink: (redirectUri?: string) => Promise<string>;
347
- };
348
- declare function useBasic(): BasicContextType;
883
+ mode: BasicMode;
884
+ }
885
+ interface ShareMountHandle {
886
+ shareId: string;
887
+ db: BasicDb;
888
+ }
889
+ declare class BasicClient {
890
+ readonly auth: AuthManager;
891
+ readonly rest: RestClient;
892
+ readonly engine: SyncEngine | null;
893
+ readonly mode: BasicMode;
894
+ readonly config: BasicClientConfig;
895
+ readonly projectId: string | undefined;
896
+ private readonly syncDb;
897
+ private readonly restDb;
898
+ private readonly debug;
899
+ private devInfo;
900
+ private syncEnabled;
901
+ private schemaChecked;
902
+ private started;
903
+ private cleanupFns;
904
+ private mounts;
905
+ private listeners;
906
+ private snapshot;
907
+ constructor(config: BasicClientConfig);
908
+ /** The database handle: offline-first in sync mode, direct API in rest mode. */
909
+ get db(): BasicDb;
910
+ /** Bootstrap: version migrations, schema check, auth initialization. */
911
+ start(): Promise<void>;
912
+ /** Sign out: server-side revoke, local auth clear, sync teardown + purge. */
913
+ signOut(): Promise<void>;
914
+ /** Stop connections and listeners; local data is kept. */
915
+ stop(): void;
916
+ /** Re-run the remote schema status check (dev toolbar). */
917
+ refreshSchemaStatus(): Promise<void>;
918
+ listRejected(): Promise<RejectedRow[]>;
919
+ clearRejected(): Promise<void>;
920
+ /** Shares granted by / received by this user for this app. */
921
+ listShares(): Promise<{
922
+ granted: Share[];
923
+ received: Share[];
924
+ }>;
925
+ /** Mount a share: separate local keyspace + subscription. */
926
+ mountShare(shareId: string): Promise<ShareMountHandle>;
927
+ unmountShare(shareId: string, options?: {
928
+ purge?: boolean;
929
+ }): Promise<void>;
930
+ getMountedShare(shareId: string): ShareMountHandle | undefined;
931
+ subscribe: (listener: () => void) => (() => void);
932
+ getSnapshot: () => BasicClientSnapshot;
933
+ private handleAuthChange;
934
+ private maybeStartSync;
935
+ private teardownLocalData;
936
+ private wireEngineEvents;
937
+ private checkSchema;
938
+ private buildSnapshot;
939
+ private publish;
940
+ }
941
+ declare function createBasicClient(config: BasicClientConfig): BasicClient;
349
942
 
350
- type AuthConfig = {
351
- scopes?: string | string[];
352
- /** @deprecated Use pds_url instead */
353
- server_url?: string;
354
- /** PDS URL for auth and data (default: https://pds.basic.id) */
355
- pds_url?: string;
356
- /** Admin server URL for connect reporting (default: https://api.basic.tech) */
357
- admin_url?: string;
358
- ws_url?: string;
359
- };
360
- type BasicProviderProps = {
361
- children: react.ReactNode;
362
- /**
363
- * @deprecated Project ID is now extracted from schema.project_id.
364
- * This prop is kept for backward compatibility but can be omitted.
365
- */
943
+ interface BasicProviderProps {
944
+ children: React.ReactNode;
945
+ /** The Basic schema object containing project_id and table definitions. */
946
+ schema?: Record<string, unknown>;
947
+ /** Project id override; normally taken from `schema.project_id`. */
366
948
  project_id?: string;
367
- /** The Basic schema object containing project_id and table definitions */
368
- schema?: any;
369
- debug?: boolean;
949
+ auth?: BasicAuthConfig;
370
950
  storage?: BasicStorage;
371
- auth?: AuthConfig;
951
+ debug?: boolean;
372
952
  /**
373
- * Database mode - determines which implementation is used
374
- * - 'sync': Uses Dexie + WebSocket for local-first sync (default)
375
- * - 'remote': Uses REST API calls directly to server
953
+ * - 'sync' (default): offline-first local replica synced over Sync/2
954
+ * - 'rest': direct REST calls, no local persistence
376
955
  */
377
- dbMode?: DBMode;
378
- /** Show floating dev toolbar (localhost, NODE_ENV=development, or debug=true). */
956
+ mode?: BasicMode;
957
+ /** Show the floating dev toolbar (localhost / NODE_ENV=development / debug). */
379
958
  devToolbar?: boolean;
380
- };
381
- declare function BasicProvider({ children, project_id: project_id_prop, schema, debug, storage, auth, dbMode, devToolbar, }: BasicProviderProps): react_jsx_runtime.JSX.Element;
959
+ /**
960
+ * Render children before auth/db are ready (default false: children render
961
+ * once the client finished bootstrapping, like previous versions).
962
+ */
963
+ renderWhileLoading?: boolean;
964
+ }
965
+ declare function BasicProvider({ children, schema, project_id, auth, storage, debug, mode, devToolbar, renderWhileLoading, }: BasicProviderProps): React.JSX.Element;
382
966
 
383
- type BasicDevToolbarProps = {
384
- /** When false, toolbar does not render. Defaults to true when used standalone. */
385
- enabled?: boolean;
386
- /** Same as BasicProvider `debug` — when true, toolbar shows even off localhost. */
387
- debug?: boolean;
388
- };
389
967
  /**
390
- * Floating dev-only toolbar: auth, DB/sync, and schema status. Requires `BasicProvider` with `debug` or localhost / NODE_ENV=development for visibility unless `enabled` is forced.
968
+ * Reactive live queries against the local database (sync mode).
969
+ * Re-export of dexie-react-hooks' `useLiveQuery` — any read through
970
+ * `db.table(...)` (or its `ref` Dexie table) is observable.
971
+ *
972
+ * ```tsx
973
+ * const todos = useQuery(() => db.table('todos').getAll())
974
+ * ```
975
+ */
976
+ declare const useQuery: typeof useLiveQuery;
977
+ /** The BasicClient instance from the nearest provider. */
978
+ declare function useBasicClient(): BasicClient;
979
+ interface UseAuthResult {
980
+ /** Auth bootstrap finished (a session may or may not exist). */
981
+ isReady: boolean;
982
+ isSignedIn: boolean;
983
+ status: AuthStatus;
984
+ errorCode: string | null;
985
+ user: User | null;
986
+ did: string | null;
987
+ /** Space-separated scopes granted in the current access token. */
988
+ scope: string | null;
989
+ hasScope: (scope: string) => boolean;
990
+ missingScopes: () => string[];
991
+ signIn: (redirectUri?: string) => Promise<void>;
992
+ signInWithHandle: (handle: string) => Promise<void>;
993
+ signInWithCode: (code: string, state?: string) => Promise<AuthResult>;
994
+ signOut: () => Promise<void>;
995
+ getToken: (options?: GetTokenOptions$1) => Promise<string>;
996
+ getSignInUrl: (redirectUri?: string) => Promise<string>;
997
+ }
998
+ /** Auth state + actions. */
999
+ declare function useAuth(): UseAuthResult;
1000
+ /** The database handle (own channel). Offline-first in sync mode. */
1001
+ declare function useDb(): BasicDb;
1002
+ interface UseSyncStatusResult {
1003
+ /** Engine status: idle | connecting | online | offline | auth_required | revoked | stopped */
1004
+ status: SyncStatus;
1005
+ /** True when the schema is valid + published and sync can run. */
1006
+ enabled: boolean;
1007
+ /** Locally queued ops not yet confirmed by the server. */
1008
+ pendingCount: number;
1009
+ /** Terminally rejected ops (poison-op rule — never re-pushed). */
1010
+ listRejected: () => Promise<RejectedRow[]>;
1011
+ clearRejected: () => Promise<void>;
1012
+ }
1013
+ /** Sync engine state: connection status, pending queue depth, rejected ops. */
1014
+ declare function useSyncStatus(): UseSyncStatusResult;
1015
+ interface UseSharesResult {
1016
+ /** Shares this user granted to others (for this app). */
1017
+ granted: Share[];
1018
+ /** Shares others granted to this user (mountable). */
1019
+ received: Share[];
1020
+ isLoading: boolean;
1021
+ error: Error | null;
1022
+ refresh: () => Promise<void>;
1023
+ }
1024
+ /** List shares granted by / received by the signed-in user for this app. */
1025
+ declare function useShares(): UseSharesResult;
1026
+ interface UseShareResult {
1027
+ /** Table API over the mounted share's keyspace; null until mounted. */
1028
+ db: BasicDb | null;
1029
+ status: 'mounting' | 'mounted' | 'error' | 'revoked';
1030
+ error: Error | null;
1031
+ }
1032
+ /**
1033
+ * Mount a share (data another user granted to you) and get a db handle for
1034
+ * it. The mount lives in its own local keyspace with its own cursor and
1035
+ * pending queue — never merged with your own data.
1036
+ */
1037
+ declare function useShare(shareId: string | null | undefined): UseShareResult;
1038
+ interface UseBasicResult extends UseAuthResult {
1039
+ db: BasicDb;
1040
+ sync: UseSyncStatusResult;
1041
+ /** Local schema vs server status; null if no schema on the provider. */
1042
+ devInfo: BasicClientSnapshot['devInfo'];
1043
+ refreshSchemaStatus: () => Promise<void>;
1044
+ client: BasicClient;
1045
+ }
1046
+ /** Umbrella hook: auth + db + sync status. */
1047
+ declare function useBasic(): UseBasicResult;
1048
+
1049
+ /**
1050
+ * SyncConnection — one multiplexed WebSocket to `wss://<pds>/sync/`
1051
+ * (SYNC_V2.md §6.9: one socket, many streams).
1052
+ *
1053
+ * Owns the transport only: hello handshake, heartbeat ping, exponential
1054
+ * backoff reconnect, and token refresh on auth rejection. Everything above
1055
+ * the socket (subscriptions, cursors, pending queues) belongs to SyncEngine,
1056
+ * which receives every parsed server message via `onMessage`.
391
1057
  */
392
- declare function BasicDevToolbar({ enabled, debug }: BasicDevToolbarProps): react_jsx_runtime.JSX.Element | null;
1058
+
1059
+ type ConnectionStatus = 'idle' | 'connecting' | 'online' | 'offline' | 'auth_failed' | 'stopped';
1060
+ interface SyncConnectionOptions {
1061
+ /** e.g. `wss://pds.basic.id/sync/` */
1062
+ wsUrl: string;
1063
+ getToken: (options?: {
1064
+ forceRefresh?: boolean;
1065
+ }) => Promise<string>;
1066
+ /** Pass the `ws` constructor in Node; defaults to global WebSocket. */
1067
+ WebSocketImpl?: typeof WebSocket;
1068
+ /** Heartbeat interval (default 30s per spec). */
1069
+ heartbeatMs?: number;
1070
+ onWelcome: (msg: WelcomeMsg) => void;
1071
+ onMessage: (msg: ServerMsg) => void;
1072
+ onStatus: (status: ConnectionStatus) => void;
1073
+ log?: (...args: unknown[]) => void;
1074
+ }
1075
+ declare class SyncConnection {
1076
+ private readonly opts;
1077
+ private readonly WS;
1078
+ private readonly heartbeatMs;
1079
+ private ws;
1080
+ private _status;
1081
+ private stopped;
1082
+ private reconnectDelay;
1083
+ private timers;
1084
+ private heartbeatTimer;
1085
+ /** One forced-refresh reconnect attempt per auth rejection. */
1086
+ private authRetryUsed;
1087
+ private removeOnlineListener;
1088
+ constructor(opts: SyncConnectionOptions);
1089
+ get status(): ConnectionStatus;
1090
+ get isOnline(): boolean;
1091
+ start(): void;
1092
+ stop(): void;
1093
+ /** Send a message; returns false when the socket is not open. */
1094
+ send(msg: ClientMsg): boolean;
1095
+ /** Refresh auth on the live socket (no reconnect). */
1096
+ sendToken(): Promise<void>;
1097
+ /**
1098
+ * The server rejected our token (`UNAUTHORIZED` / `TOKEN_EXPIRED` + close).
1099
+ * Retry once with a force-refreshed token; give up (status `auth_failed`)
1100
+ * when the refresh itself fails or a fresh token is rejected again.
1101
+ */
1102
+ handleAuthRejection(): void;
1103
+ private open;
1104
+ private handleMessage;
1105
+ private startHeartbeat;
1106
+ private stopHeartbeat;
1107
+ private scheduleReconnect;
1108
+ private listenForNetwork;
1109
+ private clearTimers;
1110
+ private setStatus;
1111
+ private log;
1112
+ }
393
1113
 
394
1114
  type ResolvedDid = {
395
1115
  did: string;
@@ -418,4 +1138,15 @@ declare function resolveDid(did: string): Promise<ResolvedDid>;
418
1138
  */
419
1139
  declare function resolveHandle(handle: string): Promise<ResolvedDid>;
420
1140
 
421
- export { type AuthConfig, type AuthError, type AuthResult, type BasicContextType, type BasicDB, BasicDevToolbar, type BasicDevToolbarProps, BasicProvider, type BasicProviderProps, type BasicSchemaDevInfo, type BasicStorage, type Collection, type DBMode, DBStatus, type GetTokenOptions$1 as GetTokenOptions, LocalStorageAdapter, NotAuthenticatedError, RemoteCollection, RemoteDB, type RemoteDBConfig, RemoteDBError, type ResolvedDid, STORAGE_KEYS, resolveDid, resolveDidWebUrl, resolveHandle, useBasic };
1141
+ type BasicDevToolbarProps = {
1142
+ /** When false, toolbar does not render. Defaults to true when used standalone. */
1143
+ enabled?: boolean;
1144
+ /** Same as BasicProvider `debug` — when true, toolbar shows even off localhost. */
1145
+ debug?: boolean;
1146
+ };
1147
+ /**
1148
+ * Floating dev-only toolbar: auth, DB/sync, and schema status. Requires `BasicProvider` with `debug` or localhost / NODE_ENV=development for visibility unless `enabled` is forced.
1149
+ */
1150
+ declare function BasicDevToolbar({ enabled, debug }: BasicDevToolbarProps): React.JSX.Element | null;
1151
+
1152
+ export { AuthManager, type AuthResult, type AuthStatus, type BasicAuthConfig, BasicClient, type BasicClientConfig, type BasicClientSnapshot, type BasicDb, BasicDevToolbar, type BasicDevToolbarProps, type BasicMode, BasicProvider, type BasicProviderProps, type BasicRecord, type BasicSchemaDevInfo, type BasicStorage, type BasicTable, type ChangesPage, type ConnectionStatus, DEFAULT_LIMITS, type GetTokenOptions$1 as GetTokenOptions, LocalStorageAdapter, type LoggedOp, NotAuthenticatedError, OWN_SUB, type OpEnvelope, type OpType, PROTOCOL_VERSION, type PdsEndpoints, type PendingRow, type PushResult, type RejectedRow, type ResolvedDid, RestClient, type RestClientOptions, RestDb, RestError, type RestRecord, STORAGE_KEYS, type Share, type ShareMountHandle, type SharePermission, type ShareSelector, type Snapshot, type SubscriptionState, SyncConnection, SyncDb, SyncEngine, type SyncEngineEvents, type SyncEngineOptions, type SyncErrorCode, type SyncLimits, type SyncStatus, SyncStore, type SyncStoreSchema, type Token, type UseAuthResult, type UseBasicResult, type UseShareResult, type UseSharesResult, type UseSyncStatusResult, type User, applyOpToData, createBasicClient, isAuthError, isRebootstrapError, isRevocationError, isTerminalOpError, mintOpId, resolveDid, resolveDidWebUrl, resolveHandle, shareSubKey, useAuth, useBasic, useBasicClient, useDb, useQuery, useShare, useShares, useSyncStatus };