@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.
@@ -1,591 +0,0 @@
1
- import React, {
2
- useCallback,
3
- useEffect,
4
- useRef,
5
- useState,
6
- Suspense,
7
- lazy,
8
- } from 'react'
9
-
10
- import { BasicSync, initDexieExtensions } from './sync'
11
- import { RemoteDB, DBMode, BasicDB } from './core/db'
12
- import { AuthManager } from './core/auth/AuthManager'
13
- import type {
14
- User,
15
- AuthResult,
16
- GetTokenOptions,
17
- PdsEndpoints,
18
- AuthStatus,
19
- } from './core/auth/AuthManager'
20
-
21
- import { log } from './config'
22
- import { version as currentVersion } from '../package.json'
23
- import { createVersionUpdater } from './updater/versionUpdater'
24
- import { getMigrations } from './updater/updateMigrations'
25
- import { BasicStorage, LocalStorageAdapter } from './utils/storage'
26
- import {
27
- isDevelopment,
28
- checkForNewVersion,
29
- getSyncStatus,
30
- } from './utils/network'
31
- import { validateAndCheckSchema } from './utils/schema'
32
- import {
33
- BasicContext,
34
- DBStatus,
35
- noDb,
36
- type BasicSchemaDevInfo,
37
- } from './context'
38
-
39
- const BasicDevToolbar = lazy(() =>
40
- import('./dev/BasicDevToolbar').then((m) => ({ default: m.BasicDevToolbar })),
41
- )
42
-
43
- export type { BasicStorage, LocalStorageAdapter } from './utils/storage'
44
- export type { DBMode, BasicDB, Collection } from './core/db'
45
- export type {
46
- Token,
47
- User,
48
- AuthResult,
49
- GetTokenOptions,
50
- PdsEndpoints,
51
- AuthStatus,
52
- } from './core/auth/AuthManager'
53
- export type { BasicContextType, BasicSchemaDevInfo } from './context'
54
- export { DBStatus, useBasic, BasicContext } from './context'
55
-
56
- export type AuthConfig = {
57
- scopes?: string | string[]
58
- /** @deprecated Use pds_url instead */
59
- server_url?: string
60
- /** PDS URL for auth and data (default: https://pds.basic.id) */
61
- pds_url?: string
62
- /** Admin server URL for connect reporting (default: https://api.basic.tech) */
63
- admin_url?: string
64
- ws_url?: string
65
- }
66
-
67
- export type BasicProviderProps = {
68
- children: React.ReactNode
69
- /**
70
- * @deprecated Project ID is now extracted from schema.project_id.
71
- * This prop is kept for backward compatibility but can be omitted.
72
- */
73
- project_id?: string
74
- /** The Basic schema object containing project_id and table definitions */
75
- schema?: any
76
- debug?: boolean
77
- storage?: BasicStorage
78
- auth?: AuthConfig
79
- /**
80
- * Database mode - determines which implementation is used
81
- * - 'sync': Uses Dexie + WebSocket for local-first sync (default)
82
- * - 'remote': Uses REST API calls directly to server
83
- */
84
- dbMode?: DBMode
85
- /** Show floating dev toolbar (localhost, NODE_ENV=development, or debug=true). */
86
- devToolbar?: boolean
87
- }
88
-
89
- const DEFAULT_AUTH_CONFIG = {
90
- scopes: 'profile,email,app:admin',
91
- pds_url: 'https://pds.basic.id',
92
- admin_url: 'https://api.basic.tech',
93
- ws_url: 'wss://pds.basic.id/ws',
94
- } as const
95
-
96
- type ErrorObject = {
97
- code: string
98
- title: string
99
- message: string
100
- }
101
-
102
- type AuthSnapshot = {
103
- isSignedIn: boolean
104
- hasToken: boolean
105
- isAuthReady: boolean
106
- authStatus: AuthStatus
107
- authErrorCode: string | null
108
- user: User | null
109
- did: string | null
110
- tokenScope: string | null
111
- }
112
-
113
- function snapshotAuth(mgr: AuthManager): AuthSnapshot {
114
- return {
115
- isSignedIn: mgr.isSignedIn,
116
- hasToken: !!mgr.token,
117
- isAuthReady: mgr.isAuthReady,
118
- authStatus: mgr.authStatus,
119
- authErrorCode: mgr.authErrorCode,
120
- user: mgr.user,
121
- did: mgr.did,
122
- tokenScope: mgr.tokenScope,
123
- }
124
- }
125
-
126
- export function BasicProvider({
127
- children,
128
- project_id: project_id_prop,
129
- schema,
130
- debug = false,
131
- storage,
132
- auth,
133
- dbMode = 'sync',
134
- devToolbar = false,
135
- }: BasicProviderProps) {
136
- const project_id = schema?.project_id || project_id_prop
137
-
138
- if (auth?.server_url && !auth?.pds_url) {
139
- log('Warning: auth.server_url is deprecated, use auth.pds_url instead')
140
- }
141
- const authConfig = {
142
- scopes: auth?.scopes || DEFAULT_AUTH_CONFIG.scopes,
143
- pds_url: auth?.pds_url || auth?.server_url || DEFAULT_AUTH_CONFIG.pds_url,
144
- admin_url: auth?.admin_url || DEFAULT_AUTH_CONFIG.admin_url,
145
- ws_url: auth?.ws_url || DEFAULT_AUTH_CONFIG.ws_url,
146
- }
147
-
148
- const scopesString = Array.isArray(authConfig.scopes)
149
- ? authConfig.scopes.join(' ')
150
- : authConfig.scopes
151
-
152
- const storageRef = useRef<BasicStorage>(storage || new LocalStorageAdapter())
153
- const storageAdapter = storageRef.current
154
-
155
- const schemaRef = useRef(schema)
156
- schemaRef.current = schema
157
-
158
- const [authState, setAuthState] = useState<AuthSnapshot>({
159
- isSignedIn: false,
160
- hasToken: false,
161
- isAuthReady: false,
162
- authStatus: 'bootstrapping',
163
- authErrorCode: null,
164
- user: null,
165
- did: null,
166
- tokenScope: null,
167
- })
168
-
169
- const authRef = useRef<AuthManager>(null!)
170
- if (!authRef.current) {
171
- authRef.current = new AuthManager(
172
- {
173
- projectId: project_id,
174
- scopes: scopesString,
175
- pdsUrl: authConfig.pds_url,
176
- adminUrl: authConfig.admin_url,
177
- debug,
178
- },
179
- storageAdapter,
180
- () => setAuthState(snapshotAuth(authRef.current)),
181
- )
182
- }
183
-
184
- const syncRef = useRef<BasicSync | null>(null)
185
- const remoteDbRef = useRef<RemoteDB | null>(null)
186
- const [shouldConnect, setShouldConnect] = useState(false)
187
- const [dbStatus, setDbStatus] = useState<DBStatus>(DBStatus.OFFLINE)
188
- const [isDbReady, setIsDbReady] = useState(false)
189
- const [error, setError] = useState<ErrorObject | null>(null)
190
- const [schemaDevInfo, setSchemaDevInfo] = useState<BasicSchemaDevInfo | null>(
191
- null,
192
- )
193
-
194
- const isDevMode = () => isDevelopment(debug)
195
-
196
- const refreshSchemaStatus = useCallback(async () => {
197
- const s = schemaRef.current
198
- if (!s) {
199
- setSchemaDevInfo(
200
- project_id
201
- ? {
202
- projectId: project_id,
203
- localVersion: undefined,
204
- status: 'no_schema',
205
- valid: false,
206
- lastCheckedAt: Date.now(),
207
- }
208
- : null,
209
- )
210
- return
211
- }
212
- const result = await validateAndCheckSchema(s)
213
- if (!result.isValid) {
214
- const errText =
215
- result.errors
216
- ?.map((e: { message?: string }) => e.message || '')
217
- .join('; ') || 'invalid'
218
- setSchemaDevInfo({
219
- projectId: s.project_id ?? null,
220
- localVersion: s.version,
221
- status: 'invalid',
222
- valid: false,
223
- lastCheckedAt: Date.now(),
224
- error: errText,
225
- })
226
- return
227
- }
228
- setSchemaDevInfo({
229
- projectId: s.project_id ?? null,
230
- localVersion: s.version,
231
- status: result.schemaStatus.status ?? 'unknown',
232
- valid: result.schemaStatus.valid,
233
- lastCheckedAt: Date.now(),
234
- })
235
- }, [project_id])
236
-
237
- useEffect(() => {
238
- const runVersionUpdater = async () => {
239
- try {
240
- const versionUpdater = createVersionUpdater(
241
- storageAdapter,
242
- currentVersion,
243
- getMigrations(),
244
- )
245
- const updateResult = await versionUpdater.checkAndUpdate()
246
-
247
- if (updateResult.updated) {
248
- log(
249
- `App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`,
250
- )
251
- } else {
252
- log(`App version ${updateResult.toVersion} is current`)
253
- }
254
- } catch (error) {
255
- log('Version update failed:', error)
256
- }
257
- }
258
-
259
- runVersionUpdater()
260
- authRef.current.initialize()
261
-
262
- return authRef.current.setupNetworkListeners()
263
- }, [])
264
-
265
- useEffect(() => {
266
- async function initSyncDb(options: { shouldConnect: boolean }) {
267
- if (!syncRef.current) {
268
- log('Initializing Basic Sync DB')
269
-
270
- await initDexieExtensions()
271
-
272
- syncRef.current = new BasicSync('basicdb', { schema: schema })
273
-
274
- syncRef.current.syncable.on('statusChanged', (status: number) => {
275
- const newStatus = getSyncStatus(status) as DBStatus
276
- setDbStatus(newStatus)
277
-
278
- if (newStatus === DBStatus.ERROR_WILL_RETRY) {
279
- log(
280
- 'Sync entered ERROR_WILL_RETRY - reconciling auth session before retry',
281
- )
282
- authRef.current
283
- .reconcileSession('sync retry', {
284
- forceRefresh: true,
285
- throttleMs: 0,
286
- })
287
- .catch(() => {})
288
- }
289
- })
290
-
291
- if (options.shouldConnect) {
292
- setShouldConnect(true)
293
- } else {
294
- log('Sync is disabled')
295
- }
296
-
297
- setIsDbReady(true)
298
- }
299
- }
300
-
301
- function initRemoteDb() {
302
- if (!remoteDbRef.current) {
303
- if (!project_id) {
304
- setError({
305
- code: 'missing_project_id',
306
- title: 'Project ID Required',
307
- message:
308
- 'Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop.',
309
- })
310
- setIsDbReady(true)
311
- return
312
- }
313
-
314
- log('Initializing Basic Remote DB')
315
- remoteDbRef.current = new RemoteDB({
316
- serverUrl: authConfig.pds_url,
317
- projectId: project_id,
318
- getToken: (opts) => authRef.current.getToken(opts),
319
- schema: schema,
320
- debug: debug,
321
- onAuthError: (error) => {
322
- log('RemoteDB auth error:', error)
323
- if (error.errorType === 'forbidden') {
324
- log('403 Forbidden - user lacks required scope, not signing out')
325
- return
326
- }
327
- authRef.current
328
- .reconcileSession(`remote db ${error.errorType}`, {
329
- forceRefresh: error.errorType !== 'network',
330
- throttleMs: 0,
331
- })
332
- .catch((reconcileError) => {
333
- log('RemoteDB auth recovery failed:', reconcileError)
334
- })
335
- },
336
- })
337
- setDbStatus(DBStatus.ONLINE)
338
- setIsDbReady(true)
339
- }
340
- }
341
-
342
- async function checkSchema() {
343
- const result = await validateAndCheckSchema(schema)
344
-
345
- if (!result.isValid) {
346
- let errorMessage = ''
347
- if (result.errors) {
348
- result.errors.forEach((err: any, index: number) => {
349
- errorMessage += `${index + 1}: ${err.message} - at ${err.instancePath}\n`
350
- })
351
- }
352
- setSchemaDevInfo({
353
- projectId: schema?.project_id ?? null,
354
- localVersion: schema?.version,
355
- status: 'invalid',
356
- valid: false,
357
- lastCheckedAt: Date.now(),
358
- error: errorMessage.trim() || undefined,
359
- })
360
- setError({
361
- code: 'schema_invalid',
362
- title: 'Basic Schema is invalid!',
363
- message: errorMessage,
364
- })
365
- setIsDbReady(true)
366
- return null
367
- }
368
-
369
- setSchemaDevInfo({
370
- projectId: schema?.project_id ?? null,
371
- localVersion: schema?.version,
372
- status: result.schemaStatus.status ?? 'unknown',
373
- valid: result.schemaStatus.valid,
374
- lastCheckedAt: Date.now(),
375
- })
376
-
377
- if (dbMode === 'remote') {
378
- initRemoteDb()
379
- } else {
380
- if (result.schemaStatus.valid) {
381
- await initSyncDb({ shouldConnect: true })
382
- } else {
383
- if (result.schemaStatus.status === 'unpublished') {
384
- log(
385
- 'Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync.',
386
- )
387
- } else {
388
- log('Schema is invalid!', result.schemaStatus)
389
- }
390
- await initSyncDb({ shouldConnect: false })
391
- }
392
- }
393
-
394
- checkForNewVersion()
395
- }
396
-
397
- if (schema) {
398
- checkSchema()
399
- } else {
400
- setSchemaDevInfo(
401
- project_id
402
- ? {
403
- projectId: project_id,
404
- localVersion: undefined,
405
- status: 'no_schema',
406
- valid: false,
407
- lastCheckedAt: Date.now(),
408
- }
409
- : null,
410
- )
411
- if (dbMode === 'remote' && project_id) {
412
- initRemoteDb()
413
- } else {
414
- setIsDbReady(true)
415
- }
416
- }
417
- }, [])
418
-
419
- useEffect(() => {
420
- if (
421
- authState.hasToken &&
422
- syncRef.current &&
423
- authState.isSignedIn &&
424
- authState.authStatus !== 'reauth_required' &&
425
- shouldConnect
426
- ) {
427
- log('connecting to db...')
428
-
429
- syncRef.current
430
- ?.connect({
431
- getToken: (opts?: GetTokenOptions) => authRef.current.getToken(opts),
432
- ws_url: authConfig.ws_url,
433
- })
434
- .catch((e: any) => {
435
- log('error connecting to db', e)
436
- })
437
- }
438
- }, [
439
- authState.authStatus,
440
- authState.isSignedIn,
441
- authState.hasToken,
442
- shouldConnect,
443
- ])
444
-
445
- useEffect(() => {
446
- if (authState.authStatus !== 'reauth_required' || !syncRef.current) {
447
- return
448
- }
449
-
450
- log('Auth requires reauthentication - disconnecting sync without deleting local DB')
451
- setDbStatus(DBStatus.ERROR_TOKEN_EXPIRED)
452
- syncRef.current
453
- .disconnect({ ws_url: authConfig.ws_url })
454
- .catch((disconnectError: unknown) => {
455
- log('Error disconnecting sync after auth invalidation:', disconnectError)
456
- })
457
- }, [authConfig.ws_url, authState.authStatus])
458
-
459
- // TODO: replace reload with proper sync DB teardown + re-init so
460
- // sign-out → sign-in works without a full page reload.
461
- const handleSignOut = async () => {
462
- await authRef.current.signOut()
463
- if (syncRef.current) {
464
- try {
465
- await syncRef.current.close()
466
- await syncRef.current.delete({ disableAutoOpen: false })
467
- syncRef.current = null
468
- } catch (error) {
469
- console.error('Error during database cleanup:', error)
470
- }
471
- }
472
- if (typeof window !== 'undefined') {
473
- window.location.reload()
474
- }
475
- }
476
-
477
- const handleSignIn = async () => {
478
- try {
479
- await authRef.current.signIn()
480
- } catch (error) {
481
- if (isDevMode()) {
482
- setError({
483
- code: 'signin_error',
484
- title: 'Sign-in Failed',
485
- message:
486
- (error as Error).message ||
487
- 'An error occurred during sign-in. Please try again.',
488
- })
489
- }
490
- throw error
491
- }
492
- }
493
-
494
- const handleSignInWithHandle = async (handle: string) => {
495
- try {
496
- await authRef.current.signInWithHandle(handle)
497
- } catch (error) {
498
- if (isDevMode()) {
499
- setError({
500
- code: 'signin_error',
501
- title: 'Sign-in Failed',
502
- message:
503
- (error as Error).message ||
504
- 'An error occurred during sign-in. Please try again.',
505
- })
506
- }
507
- throw error
508
- }
509
- }
510
-
511
- const getCurrentDb = (): BasicDB => {
512
- if (dbMode === 'remote') {
513
- return remoteDbRef.current || noDb
514
- }
515
- return syncRef.current || noDb
516
- }
517
-
518
- const contextValue = {
519
- isReady: authState.isAuthReady,
520
- isSignedIn: authState.isSignedIn,
521
- authStatus: authState.authStatus,
522
- authErrorCode: authState.authErrorCode,
523
- user: authState.user,
524
- did: authState.did,
525
- scope: authState.tokenScope,
526
- hasScope: (s: string) => authRef.current.hasScope(s),
527
- missingScopes: () => authRef.current.missingScopes(),
528
-
529
- signIn: handleSignIn,
530
- signInWithHandle: handleSignInWithHandle,
531
- signOut: handleSignOut,
532
- signInWithCode: (code: string, state?: string) =>
533
- authRef.current.signInWithCode(code, state),
534
-
535
- getToken: (opts?: GetTokenOptions) => authRef.current.getToken(opts),
536
- getSignInUrl: (redirectUri?: string) =>
537
- authRef.current.getSignInUrl(redirectUri),
538
-
539
- db: getCurrentDb(),
540
- dbStatus,
541
- dbMode,
542
-
543
- devInfo: schemaDevInfo,
544
- refreshSchemaStatus,
545
-
546
- isAuthReady: authState.isAuthReady,
547
- signin: handleSignIn,
548
- signout: handleSignOut,
549
- signinWithCode: (code: string, state?: string) =>
550
- authRef.current.signInWithCode(code, state),
551
- getSignInLink: (redirectUri?: string) =>
552
- authRef.current.getSignInUrl(redirectUri),
553
- }
554
-
555
- return (
556
- <BasicContext.Provider value={contextValue}>
557
- {error && isDevMode() && <ErrorDisplay error={error} />}
558
- {devToolbar && isDevMode() && (
559
- <Suspense fallback={null}>
560
- <BasicDevToolbar debug={debug} />
561
- </Suspense>
562
- )}
563
- {isDbReady && authState.isAuthReady && children}
564
- </BasicContext.Provider>
565
- )
566
- }
567
-
568
- function ErrorDisplay({ error }: { error: ErrorObject }) {
569
- return (
570
- <div
571
- style={{
572
- position: 'absolute',
573
- top: 20,
574
- left: 20,
575
- color: 'black',
576
- backgroundColor: '#f8d7da',
577
- border: '1px solid #f5c6cb',
578
- borderRadius: '4px',
579
- padding: '20px',
580
- maxWidth: '400px',
581
- margin: '20px auto',
582
- boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
583
- fontFamily: 'monospace',
584
- }}
585
- >
586
- <h3 style={{ fontSize: '0.8rem', opacity: 0.8 }}>code: {error.code}</h3>
587
- <h1 style={{ fontSize: '1.2rem', lineHeight: 1.5 }}>{error.title}</h1>
588
- <p>{error.message}</p>
589
- </div>
590
- )
591
- }
package/src/config.ts DELETED
@@ -1,9 +0,0 @@
1
- export const log = (...args: any[]) => {
2
- try {
3
- if (localStorage.getItem('basic_debug') === 'true') {
4
- console.log('[basic]', ...args)
5
- }
6
- } catch (e) {
7
- // silently fail if localStorage is unavailable (SSR)
8
- }
9
- }
package/src/context.tsx DELETED
@@ -1,122 +0,0 @@
1
- import { createContext, useContext } from 'react'
2
- import type { BasicDB } from './core/db'
3
- import type {
4
- User,
5
- AuthResult,
6
- GetTokenOptions,
7
- AuthStatus,
8
- } from './core/auth/AuthManager'
9
- import type { DBMode } from './core/db'
10
-
11
- export enum DBStatus {
12
- LOADING = 'LOADING',
13
- OFFLINE = 'OFFLINE',
14
- CONNECTING = 'CONNECTING',
15
- ONLINE = 'ONLINE',
16
- SYNCING = 'SYNCING',
17
- ERROR = 'ERROR',
18
- /** Sync-layer error with automatic retry (maps from dexie-syncable status 4). */
19
- ERROR_WILL_RETRY = 'ERROR_WILL_RETRY',
20
- /**
21
- * Auth-driven status: set by the provider when `authStatus` transitions to
22
- * `reauth_required`, causing sync to disconnect. Unlike the other statuses
23
- * this is NOT mapped from a dexie-syncable status code — it is set
24
- * programmatically by `BasicProvider` to signal that the token is
25
- * definitively invalid and the user must re-authenticate before sync can
26
- * resume.
27
- */
28
- ERROR_TOKEN_EXPIRED = 'ERROR_TOKEN_EXPIRED',
29
- }
30
-
31
- /** Snapshot of local schema vs server (for dev toolbar and debugging). */
32
- export type BasicSchemaDevInfo = {
33
- projectId: string | null
34
- localVersion: number | undefined
35
- status: string
36
- valid: boolean
37
- lastCheckedAt: number
38
- error?: string
39
- }
40
-
41
- /**
42
- * Context type for useBasic hook
43
- */
44
- export type BasicContextType = {
45
- isReady: boolean
46
- isSignedIn: boolean
47
- authStatus: AuthStatus
48
- authErrorCode: string | null
49
- user: User | null
50
- did: string | null
51
- scope: string | null
52
- hasScope: (scope: string) => boolean
53
- missingScopes: () => string[]
54
-
55
- signIn: () => Promise<void>
56
- signInWithHandle: (handle: string) => Promise<void>
57
- signOut: () => Promise<void>
58
- signInWithCode: (code: string, state?: string) => Promise<AuthResult>
59
-
60
- getToken: (options?: GetTokenOptions) => Promise<string>
61
- getSignInUrl: (redirectUri?: string) => Promise<string>
62
-
63
- db: BasicDB
64
- dbStatus: DBStatus
65
- dbMode: DBMode
66
-
67
- /** Local schema vs server status; null if no schema on the provider. */
68
- devInfo: BasicSchemaDevInfo | null
69
- /** Re-run remote schema check (dev toolbar). */
70
- refreshSchemaStatus: () => Promise<void>
71
-
72
- isAuthReady: boolean
73
- signin: () => Promise<void>
74
- signout: () => Promise<void>
75
- signinWithCode: (code: string, state?: string) => Promise<AuthResult>
76
- getSignInLink: (redirectUri?: string) => Promise<string>
77
- }
78
-
79
- const noDb: BasicDB = {
80
- collection: () => {
81
- throw new Error('no basicdb found - initialization failed. double check your schema.')
82
- },
83
- }
84
-
85
- export const BasicContext = createContext<BasicContextType>({
86
- isReady: false,
87
- isSignedIn: false,
88
- authStatus: 'bootstrapping',
89
- authErrorCode: null,
90
- user: null,
91
- did: null,
92
- scope: null,
93
- hasScope: () => false,
94
- missingScopes: () => [],
95
-
96
- signIn: () => Promise.resolve(),
97
- signInWithHandle: () => Promise.resolve(),
98
- signOut: () => Promise.resolve(),
99
- signInWithCode: () => Promise.resolve({ success: false }),
100
-
101
- getToken: (_options?: GetTokenOptions) => Promise.reject(new Error('no token')),
102
- getSignInUrl: () => Promise.resolve(''),
103
-
104
- db: noDb,
105
- dbStatus: DBStatus.LOADING,
106
- dbMode: 'sync',
107
-
108
- devInfo: null,
109
- refreshSchemaStatus: async () => {},
110
-
111
- isAuthReady: false,
112
- signin: () => Promise.resolve(),
113
- signout: () => Promise.resolve(),
114
- signinWithCode: () => Promise.resolve({ success: false }),
115
- getSignInLink: () => Promise.resolve(''),
116
- })
117
-
118
- export function useBasic() {
119
- return useContext(BasicContext)
120
- }
121
-
122
- export { noDb }