@basictech/react 0.7.0-beta.6 → 0.7.0-beta.7

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,7 +1,8 @@
1
1
  import React, { createContext, useContext, useEffect, useState, useRef } from 'react'
2
2
  import { jwtDecode } from 'jwt-decode'
3
3
 
4
- import { BasicSync } from './sync'
4
+ import { BasicSync, initDexieExtensions } from './sync'
5
+ import { RemoteDB, DBMode, BasicDB } from './core/db'
5
6
 
6
7
  import { log } from './config'
7
8
  import { version as currentVersion } from '../package.json'
@@ -12,6 +13,7 @@ import { isDevelopment, checkForNewVersion, cleanOAuthParamsFromUrl, getSyncStat
12
13
  import { getSchemaStatus, validateAndCheckSchema } from './utils/schema'
13
14
 
14
15
  export type { BasicStorage, LocalStorageAdapter } from './utils/storage'
16
+ export type { DBMode, BasicDB, Collection } from './core/db'
15
17
 
16
18
  export type AuthConfig = {
17
19
  scopes?: string | string[];
@@ -21,11 +23,22 @@ export type AuthConfig = {
21
23
 
22
24
  export type BasicProviderProps = {
23
25
  children: React.ReactNode;
26
+ /**
27
+ * @deprecated Project ID is now extracted from schema.project_id.
28
+ * This prop is kept for backward compatibility but can be omitted.
29
+ */
24
30
  project_id?: string;
31
+ /** The Basic schema object containing project_id and table definitions */
25
32
  schema?: any;
26
33
  debug?: boolean;
27
34
  storage?: BasicStorage;
28
35
  auth?: AuthConfig;
36
+ /**
37
+ * Database mode - determines which implementation is used
38
+ * - 'sync': Uses Dexie + WebSocket for local-first sync (default)
39
+ * - 'remote': Uses REST API calls directly to server
40
+ */
41
+ dbMode?: DBMode;
29
42
  }
30
43
 
31
44
  const DEFAULT_AUTH_CONFIG = {
@@ -74,30 +87,83 @@ type Token = {
74
87
  refresh_token: string,
75
88
  }
76
89
 
77
- export const BasicContext = createContext<{
78
- unicorn: string,
79
- isAuthReady: boolean,
80
- isSignedIn: boolean,
81
- user: User | null,
82
- signout: () => Promise<void>,
83
- signin: () => Promise<void>,
84
- signinWithCode: (code: string, state?: string) => Promise<{ success: boolean, error?: string }>,
85
- getToken: () => Promise<string>,
86
- getSignInLink: (redirectUri?: string) => Promise<string>,
87
- db: any,
88
- dbStatus: DBStatus
89
- }>({
90
- unicorn: "🦄",
91
- isAuthReady: false,
90
+ /**
91
+ * Auth result type for signInWithCode
92
+ */
93
+ export type AuthResult = {
94
+ success: boolean;
95
+ error?: string;
96
+ code?: string;
97
+ }
98
+
99
+ /**
100
+ * Context type for useBasic hook
101
+ */
102
+ export type BasicContextType = {
103
+ // Auth state
104
+ isReady: boolean;
105
+ isSignedIn: boolean;
106
+ user: User | null;
107
+
108
+ // Auth actions (new camelCase naming)
109
+ signIn: () => Promise<void>;
110
+ signOut: () => Promise<void>;
111
+ signInWithCode: (code: string, state?: string) => Promise<AuthResult>;
112
+
113
+ // Token management
114
+ getToken: () => Promise<string>;
115
+ getSignInUrl: (redirectUri?: string) => Promise<string>;
116
+
117
+ // DB access
118
+ db: BasicDB;
119
+ dbStatus: DBStatus;
120
+ dbMode: DBMode;
121
+
122
+ // Legacy aliases (deprecated - will be removed in future version)
123
+ /** @deprecated Use isReady instead */
124
+ isAuthReady: boolean;
125
+ /** @deprecated Use signIn instead */
126
+ signin: () => Promise<void>;
127
+ /** @deprecated Use signOut instead */
128
+ signout: () => Promise<void>;
129
+ /** @deprecated Use signInWithCode instead */
130
+ signinWithCode: (code: string, state?: string) => Promise<AuthResult>;
131
+ /** @deprecated Use getSignInUrl instead */
132
+ getSignInLink: (redirectUri?: string) => Promise<string>;
133
+ }
134
+
135
+ const noDb: BasicDB = {
136
+ collection: () => {
137
+ throw new Error('no basicdb found - initialization failed. double check your schema.')
138
+ }
139
+ }
140
+
141
+ export const BasicContext = createContext<BasicContextType>({
142
+ // Auth state
143
+ isReady: false,
92
144
  isSignedIn: false,
93
145
  user: null,
94
- signout: () => Promise.resolve(),
146
+
147
+ // Auth actions
148
+ signIn: () => Promise.resolve(),
149
+ signOut: () => Promise.resolve(),
150
+ signInWithCode: () => Promise.resolve({ success: false }),
151
+
152
+ // Token management
153
+ getToken: () => Promise.reject(new Error('no token')),
154
+ getSignInUrl: () => Promise.resolve(""),
155
+
156
+ // DB access
157
+ db: noDb,
158
+ dbStatus: DBStatus.LOADING,
159
+ dbMode: 'sync',
160
+
161
+ // Legacy aliases
162
+ isAuthReady: false,
95
163
  signin: () => Promise.resolve(),
96
- signinWithCode: () => new Promise(() => { }),
97
- getToken: () => new Promise(() => { }),
98
- getSignInLink: () => Promise.resolve(""),
99
- db: {},
100
- dbStatus: DBStatus.LOADING
164
+ signout: () => Promise.resolve(),
165
+ signinWithCode: () => Promise.resolve({ success: false }),
166
+ getSignInLink: () => Promise.resolve("")
101
167
  });
102
168
 
103
169
  type ErrorObject = {
@@ -108,12 +174,16 @@ type ErrorObject = {
108
174
 
109
175
  export function BasicProvider({
110
176
  children,
111
- project_id,
177
+ project_id: project_id_prop,
112
178
  schema,
113
179
  debug = false,
114
180
  storage,
115
- auth
181
+ auth,
182
+ dbMode = 'sync'
116
183
  }: BasicProviderProps) {
184
+ // Extract project_id from schema, fall back to prop for backward compatibility
185
+ const project_id = schema?.project_id || project_id_prop
186
+
117
187
  const [isAuthReady, setIsAuthReady] = useState(false)
118
188
  const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
119
189
  const [token, setToken] = useState<Token | null>(null)
@@ -127,6 +197,7 @@ export function BasicProvider({
127
197
  const [pendingRefresh, setPendingRefresh] = useState<boolean>(false)
128
198
 
129
199
  const syncRef = useRef<BasicSync | null>(null);
200
+ const remoteDbRef = useRef<RemoteDB | null>(null);
130
201
  const storageAdapter = storage || new LocalStorageAdapter();
131
202
 
132
203
  // Merge auth config with defaults
@@ -181,19 +252,19 @@ export function BasicProvider({
181
252
  }, [pendingRefresh, token])
182
253
 
183
254
  useEffect(() => {
184
- function initDb(options: { shouldConnect: boolean }) {
255
+ async function initSyncDb(options: { shouldConnect: boolean }) {
185
256
  if (!syncRef.current) {
186
- log('Initializing Basic DB')
257
+ log('Initializing Basic Sync DB')
258
+
259
+ // Initialize Dexie extensions before creating BasicSync
260
+ await initDexieExtensions()
261
+
187
262
  syncRef.current = new BasicSync('basicdb', { schema: schema });
188
263
 
189
264
  syncRef.current.syncable.on('statusChanged', (status: number, url: string) => {
190
265
  setDbStatus(getSyncStatus(status) as DBStatus)
191
266
  })
192
267
 
193
- // syncRef.current.syncable.getStatus().then((status: number) => {
194
- // setDbStatus(getSyncStatus(status) as DBStatus)
195
- // })
196
-
197
268
  if (options.shouldConnect) {
198
269
  setShouldConnect(true)
199
270
  } else {
@@ -204,6 +275,36 @@ export function BasicProvider({
204
275
  }
205
276
  }
206
277
 
278
+ function initRemoteDb() {
279
+ if (!remoteDbRef.current) {
280
+ if (!project_id) {
281
+ setError({
282
+ code: 'missing_project_id',
283
+ title: 'Project ID Required',
284
+ message: 'Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop.'
285
+ })
286
+ setIsReady(true)
287
+ return
288
+ }
289
+
290
+ log('Initializing Basic Remote DB')
291
+ remoteDbRef.current = new RemoteDB({
292
+ serverUrl: authConfig.server_url,
293
+ projectId: project_id,
294
+ getToken: getToken,
295
+ schema: schema,
296
+ debug: debug,
297
+ onAuthError: (error) => {
298
+ log('RemoteDB auth error:', error)
299
+ // Sign out user when authentication fails after retry
300
+ signout()
301
+ }
302
+ })
303
+ setDbStatus(DBStatus.ONLINE)
304
+ setIsReady(true)
305
+ }
306
+ }
307
+
207
308
  async function checkSchema() {
208
309
  const result = await validateAndCheckSchema(schema)
209
310
 
@@ -223,11 +324,17 @@ export function BasicProvider({
223
324
  return null
224
325
  }
225
326
 
226
- if (result.schemaStatus.valid) {
227
- initDb({ shouldConnect: true })
327
+ // Initialize the appropriate DB based on mode
328
+ if (dbMode === 'remote') {
329
+ initRemoteDb()
228
330
  } else {
229
- log('Schema is invalid!', result.schemaStatus)
230
- initDb({ shouldConnect: false })
331
+ // Sync mode
332
+ if (result.schemaStatus.valid) {
333
+ await initSyncDb({ shouldConnect: true })
334
+ } else {
335
+ log('Schema is invalid!', result.schemaStatus)
336
+ await initSyncDb({ shouldConnect: false })
337
+ }
231
338
  }
232
339
 
233
340
  checkForNewVersion()
@@ -236,7 +343,12 @@ export function BasicProvider({
236
343
  if (schema) {
237
344
  checkSchema()
238
345
  } else {
239
- setIsReady(true)
346
+ // No schema - still initialize remote DB if in remote mode
347
+ if (dbMode === 'remote' && project_id) {
348
+ initRemoteDb()
349
+ } else {
350
+ setIsReady(true)
351
+ }
240
352
  }
241
353
  }, []);
242
354
 
@@ -829,27 +941,45 @@ export function BasicProvider({
829
941
  return refreshPromise
830
942
  }
831
943
 
832
- const noDb = ({
833
- collection: () => {
834
- throw new Error('no basicdb found - initialization failed. double check your schema.')
944
+ // Get the current DB instance based on mode
945
+ const getCurrentDb = (): BasicDB => {
946
+ if (dbMode === 'remote') {
947
+ return remoteDbRef.current || noDb
835
948
  }
836
- })
949
+ return syncRef.current || noDb
950
+ }
837
951
 
838
- return (
839
- <BasicContext.Provider value={{
840
- unicorn: "🦄",
841
- isAuthReady,
842
- isSignedIn,
843
- user,
844
- signout,
845
- signin,
846
- signinWithCode,
847
- getToken,
848
- getSignInLink,
849
- db: syncRef.current ? syncRef.current : noDb,
850
- dbStatus
851
- }}>
952
+ // Create context value with new names and legacy aliases
953
+ const contextValue: BasicContextType = {
954
+ // Auth state (new naming)
955
+ isReady: isAuthReady,
956
+ isSignedIn,
957
+ user,
958
+
959
+ // Auth actions (new camelCase naming)
960
+ signIn: signin,
961
+ signOut: signout,
962
+ signInWithCode: signinWithCode,
963
+
964
+ // Token management
965
+ getToken,
966
+ getSignInUrl: getSignInLink,
967
+
968
+ // DB access
969
+ db: getCurrentDb(),
970
+ dbStatus,
971
+ dbMode,
972
+
973
+ // Legacy aliases (deprecated)
974
+ isAuthReady,
975
+ signin,
976
+ signout,
977
+ signinWithCode,
978
+ getSignInLink,
979
+ }
852
980
 
981
+ return (
982
+ <BasicContext.Provider value={contextValue}>
853
983
  {error && isDevMode() && <ErrorDisplay error={error} />}
854
984
  {isReady && children}
855
985
  </BasicContext.Provider>
@@ -0,0 +1,294 @@
1
+ import { Collection, RemoteDBConfig, RemoteDBError } from './types'
2
+ import { validateData } from '@basictech/schema'
3
+
4
+ /**
5
+ * Error thrown when user is not authenticated
6
+ */
7
+ export class NotAuthenticatedError extends Error {
8
+ constructor(message: string = 'Not authenticated') {
9
+ super(message)
10
+ this.name = 'NotAuthenticatedError'
11
+ }
12
+ }
13
+
14
+ /**
15
+ * RemoteCollection - REST API based implementation of the Collection interface
16
+ * All operations make HTTP calls to the Basic API server
17
+ */
18
+ export class RemoteCollection<T extends { id: string } = Record<string, any> & { id: string }> implements Collection<T> {
19
+ private tableName: string
20
+ private config: RemoteDBConfig
21
+
22
+ constructor(tableName: string, config: RemoteDBConfig) {
23
+ this.tableName = tableName
24
+ this.config = config
25
+ }
26
+
27
+ private log(...args: any[]) {
28
+ if (this.config.debug) {
29
+ console.log('[RemoteDB]', ...args)
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Check if an error is a "not authenticated" error
35
+ */
36
+ private isNotAuthenticatedError(error: unknown): boolean {
37
+ if (error instanceof Error) {
38
+ const message = error.message.toLowerCase()
39
+ return message.includes('no token') ||
40
+ message.includes('not authenticated') ||
41
+ message.includes('please sign in')
42
+ }
43
+ return false
44
+ }
45
+
46
+ /**
47
+ * Helper to make authenticated API requests
48
+ * Automatically retries once on 401 (token expired) by refreshing the token
49
+ */
50
+ private async request<R>(
51
+ method: string,
52
+ path: string,
53
+ body?: any,
54
+ isRetry: boolean = false
55
+ ): Promise<R> {
56
+ // Try to get token - may throw if not authenticated
57
+ const token = await this.config.getToken()
58
+ const url = `${this.config.serverUrl}${path}`
59
+
60
+ this.log(`${method} ${url}`, body ? JSON.stringify(body) : '')
61
+
62
+ const response = await fetch(url, {
63
+ method,
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ 'Authorization': `Bearer ${token}`
67
+ },
68
+ ...(body ? { body: JSON.stringify(body) } : {})
69
+ })
70
+
71
+ const responseData = await response.json().catch(() => ({}))
72
+
73
+ if (!response.ok) {
74
+ // Handle 401 Unauthorized - token may have expired
75
+ if (response.status === 401 && !isRetry) {
76
+ this.log('Got 401, retrying with fresh token...')
77
+ // getToken() should refresh the token if expired
78
+ // Retry the request once
79
+ return this.request<R>(method, path, body, true)
80
+ }
81
+
82
+ if (this.config.debug) {
83
+ console.error(`[RemoteDB] Error ${response.status}:`, responseData)
84
+ }
85
+
86
+ // Call onAuthError callback if provided and this is an auth error
87
+ if (response.status === 401 && this.config.onAuthError) {
88
+ this.config.onAuthError({
89
+ status: response.status,
90
+ message: 'Authentication failed',
91
+ response: responseData
92
+ })
93
+ }
94
+
95
+ // Try different error message fields that APIs commonly use
96
+ const errorMessage = responseData.message || responseData.error || responseData.detail ||
97
+ (typeof responseData === 'string' ? responseData : `API request failed: ${response.status}`)
98
+ throw new RemoteDBError(errorMessage, response.status, responseData)
99
+ }
100
+
101
+ this.log('Response:', responseData)
102
+ return responseData
103
+ }
104
+
105
+ /**
106
+ * Validate data against schema if available
107
+ */
108
+ private validateData(data: any, checkRequired: boolean = true): void {
109
+ if (this.config.schema) {
110
+ const result = validateData(this.config.schema, this.tableName, data, checkRequired)
111
+ if (!result.valid) {
112
+ throw new Error(result.message || 'Data validation failed')
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Get the base path for this collection
119
+ */
120
+ private get basePath(): string {
121
+ return `/account/${this.config.projectId}/db/${this.tableName}`
122
+ }
123
+
124
+ /**
125
+ * Add a new record to the collection
126
+ * The server generates the ID
127
+ * Requires authentication - throws NotAuthenticatedError if not signed in
128
+ */
129
+ async add(data: Omit<T, 'id'>): Promise<T> {
130
+ this.validateData(data, true)
131
+
132
+ try {
133
+ const result = await this.request<{ data: T }>(
134
+ 'POST',
135
+ this.basePath,
136
+ { value: data }
137
+ )
138
+ // Server returns the created record with the generated ID
139
+ return result.data
140
+ } catch (error) {
141
+ if (this.isNotAuthenticatedError(error)) {
142
+ throw new NotAuthenticatedError('Sign in required to add items')
143
+ }
144
+ throw error
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Put (upsert) a record - requires id
150
+ * Requires authentication - throws NotAuthenticatedError if not signed in
151
+ */
152
+ async put(data: T): Promise<T> {
153
+ if (!data.id) {
154
+ throw new Error('put() requires an id field')
155
+ }
156
+
157
+ // Extract id from data, send the rest in the body
158
+ const { id, ...rest } = data
159
+ this.validateData(rest, true)
160
+
161
+ try {
162
+ const result = await this.request<{ data: T }>(
163
+ 'PUT',
164
+ `${this.basePath}/${id}`,
165
+ { value: rest }
166
+ )
167
+ return result.data || data
168
+ } catch (error) {
169
+ if (this.isNotAuthenticatedError(error)) {
170
+ throw new NotAuthenticatedError('Sign in required to update items')
171
+ }
172
+ throw error
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Update an existing record by id
178
+ * Requires authentication - throws NotAuthenticatedError if not signed in
179
+ */
180
+ async update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null> {
181
+ if (!id) {
182
+ throw new Error('update() requires an id')
183
+ }
184
+
185
+ this.validateData(data, false)
186
+
187
+ try {
188
+ const result = await this.request<{ data: T }>(
189
+ 'PATCH',
190
+ `${this.basePath}/${id}`,
191
+ { value: data }
192
+ )
193
+
194
+ return result.data || null
195
+ } catch (error) {
196
+ // If record not found, return null instead of throwing
197
+ if (error instanceof RemoteDBError && error.status === 404) {
198
+ return null
199
+ }
200
+ if (this.isNotAuthenticatedError(error)) {
201
+ throw new NotAuthenticatedError('Sign in required to update items')
202
+ }
203
+ throw error
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Delete a record by id
209
+ * Requires authentication - throws NotAuthenticatedError if not signed in
210
+ */
211
+ async delete(id: string): Promise<boolean> {
212
+ if (!id) {
213
+ throw new Error('delete() requires an id')
214
+ }
215
+
216
+ try {
217
+ await this.request<any>(
218
+ 'DELETE',
219
+ `${this.basePath}/${id}`
220
+ )
221
+ return true
222
+ } catch (error) {
223
+ // If record not found, return false instead of throwing
224
+ if (error instanceof RemoteDBError && error.status === 404) {
225
+ return false
226
+ }
227
+ if (this.isNotAuthenticatedError(error)) {
228
+ throw new NotAuthenticatedError('Sign in required to delete items')
229
+ }
230
+ throw error
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Get a single record by id
236
+ * Returns null if not authenticated (graceful degradation for read operations)
237
+ */
238
+ async get(id: string): Promise<T | null> {
239
+ if (!id) {
240
+ throw new Error('get() requires an id')
241
+ }
242
+
243
+ try {
244
+ // Use the API's id query parameter for efficient single-record fetch
245
+ const result = await this.request<{ data: T[] }>(
246
+ 'GET',
247
+ `${this.basePath}?id=${id}`
248
+ )
249
+ return result.data?.[0] || null
250
+ } catch (error) {
251
+ // For get(), return null on any error (not found, not authenticated, etc.)
252
+ if (this.isNotAuthenticatedError(error)) {
253
+ this.log('Not authenticated - returning null for get()')
254
+ }
255
+ return null
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Get all records in the collection
261
+ * Returns empty array if not authenticated (graceful degradation for read operations)
262
+ */
263
+ async getAll(): Promise<T[]> {
264
+ try {
265
+ const result = await this.request<{ data: T[] }>(
266
+ 'GET',
267
+ this.basePath
268
+ )
269
+ return result.data || []
270
+ } catch (error) {
271
+ // If not authenticated, return empty array gracefully
272
+ if (this.isNotAuthenticatedError(error)) {
273
+ this.log('Not authenticated - returning empty array for getAll()')
274
+ return []
275
+ }
276
+ throw error
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Filter records using a predicate function
282
+ * Note: This fetches all records and filters client-side
283
+ * Returns empty array if not authenticated (graceful degradation for read operations)
284
+ */
285
+ async filter(fn: (item: T) => boolean): Promise<T[]> {
286
+ const all = await this.getAll()
287
+ return all.filter(fn)
288
+ }
289
+
290
+ /**
291
+ * ref is not available for remote collections
292
+ */
293
+ ref = undefined
294
+ }
@@ -0,0 +1,40 @@
1
+ import { BasicDB, Collection, RemoteDBConfig } from './types'
2
+ import { RemoteCollection } from './RemoteCollection'
3
+
4
+ /**
5
+ * RemoteDB - REST API based implementation of BasicDB
6
+ * Creates RemoteCollection instances for each table
7
+ */
8
+ export class RemoteDB implements BasicDB {
9
+ private config: RemoteDBConfig
10
+ private collections: Map<string, RemoteCollection<any>> = new Map()
11
+
12
+ constructor(config: RemoteDBConfig) {
13
+ this.config = config
14
+ }
15
+
16
+ /**
17
+ * Get a collection by name
18
+ * Collections are cached for reuse
19
+ */
20
+ collection<T extends { id: string } = Record<string, any> & { id: string }>(
21
+ name: string
22
+ ): Collection<T> {
23
+ // Return cached collection if exists
24
+ if (this.collections.has(name)) {
25
+ return this.collections.get(name) as RemoteCollection<T>
26
+ }
27
+
28
+ // Validate table exists in schema if schema is provided
29
+ if (this.config.schema?.tables && !this.config.schema.tables[name]) {
30
+ throw new Error(`Table "${name}" not found in schema`)
31
+ }
32
+
33
+ // Create and cache new collection
34
+ const collection = new RemoteCollection<T>(name, this.config)
35
+ this.collections.set(name, collection)
36
+
37
+ return collection
38
+ }
39
+ }
40
+
@@ -0,0 +1,7 @@
1
+ // Core DB exports
2
+ export type { Collection, BasicDB, DBMode, RemoteDBConfig } from './types'
3
+ export type { AuthError } from './types'
4
+ export { RemoteDBError } from './types'
5
+ export { RemoteDB } from './RemoteDB'
6
+ export { RemoteCollection, NotAuthenticatedError } from './RemoteCollection'
7
+