@basictech/react 0.8.0-beta.3 → 0.8.0-beta.4

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