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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,308 +0,0 @@
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 headers: Record<string, string> = {
63
- 'Authorization': `Bearer ${token}`
64
- }
65
- if (body) {
66
- headers['Content-Type'] = 'application/json'
67
- }
68
-
69
- const response = await fetch(url, {
70
- method,
71
- headers,
72
- ...(body ? { body: JSON.stringify(body) } : {})
73
- })
74
-
75
- const responseData = await response.json().catch(() => ({}))
76
-
77
- if (!response.ok) {
78
- // Handle 401 Unauthorized - force refresh then retry once
79
- if (response.status === 401 && !isRetry) {
80
- this.log('Got 401, forcing token refresh and retrying...')
81
- await this.config.getToken({ forceRefresh: true })
82
- return this.request<R>(method, path, body, true)
83
- }
84
-
85
- if (this.config.debug) {
86
- console.error(`[RemoteDB] Error ${response.status}:`, responseData)
87
- }
88
-
89
- // Call onAuthError callback for auth/authz errors
90
- if (this.config.onAuthError) {
91
- if (response.status === 401) {
92
- this.config.onAuthError({
93
- status: response.status,
94
- message: 'Authentication failed',
95
- response: responseData,
96
- errorType: 'expired',
97
- afterRetry: isRetry,
98
- })
99
- } else if (response.status === 403) {
100
- this.config.onAuthError({
101
- status: response.status,
102
- message: responseData.message || 'Forbidden - insufficient permissions or missing scope',
103
- response: responseData,
104
- errorType: 'forbidden',
105
- afterRetry: isRetry,
106
- })
107
- }
108
- }
109
-
110
- const errorMessage = responseData.message || responseData.error || responseData.detail ||
111
- (typeof responseData === 'string' ? responseData : `API request failed: ${response.status}`)
112
- throw new RemoteDBError(errorMessage, response.status, responseData)
113
- }
114
-
115
- this.log('Response:', responseData)
116
- return responseData
117
- }
118
-
119
- /**
120
- * Validate data against schema if available
121
- */
122
- private validateData(data: any, checkRequired: boolean = true): void {
123
- if (this.config.schema) {
124
- const result = validateData(this.config.schema, this.tableName, data, checkRequired)
125
- if (!result.valid) {
126
- throw new Error(result.message || 'Data validation failed')
127
- }
128
- }
129
- }
130
-
131
- /**
132
- * Get the base path for this collection
133
- */
134
- private get basePath(): string {
135
- return `/account/${this.config.projectId}/db/${this.tableName}`
136
- }
137
-
138
- /**
139
- * Add a new record to the collection
140
- * The server generates the ID
141
- * Requires authentication - throws NotAuthenticatedError if not signed in
142
- */
143
- async add(data: Omit<T, 'id'>): Promise<T> {
144
- this.validateData(data, true)
145
-
146
- try {
147
- const result = await this.request<{ data: T }>(
148
- 'POST',
149
- this.basePath,
150
- { value: data }
151
- )
152
- // Server returns the created record with the generated ID
153
- return result.data
154
- } catch (error) {
155
- if (this.isNotAuthenticatedError(error)) {
156
- throw new NotAuthenticatedError('Sign in required to add items')
157
- }
158
- throw error
159
- }
160
- }
161
-
162
- /**
163
- * Put (upsert) a record - requires id
164
- * Requires authentication - throws NotAuthenticatedError if not signed in
165
- */
166
- async put(data: T): Promise<T> {
167
- if (!data.id) {
168
- throw new Error('put() requires an id field')
169
- }
170
-
171
- // Extract id from data, send the rest in the body
172
- const { id, ...rest } = data
173
- this.validateData(rest, true)
174
-
175
- try {
176
- const result = await this.request<{ data: T }>(
177
- 'PUT',
178
- `${this.basePath}/${id}`,
179
- { value: rest }
180
- )
181
- return result.data || data
182
- } catch (error) {
183
- if (this.isNotAuthenticatedError(error)) {
184
- throw new NotAuthenticatedError('Sign in required to update items')
185
- }
186
- throw error
187
- }
188
- }
189
-
190
- /**
191
- * Update an existing record by id
192
- * Requires authentication - throws NotAuthenticatedError if not signed in
193
- */
194
- async update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null> {
195
- if (!id) {
196
- throw new Error('update() requires an id')
197
- }
198
-
199
- this.validateData(data, false)
200
-
201
- try {
202
- const result = await this.request<{ data: T }>(
203
- 'PATCH',
204
- `${this.basePath}/${id}`,
205
- { value: data }
206
- )
207
-
208
- return result.data || null
209
- } catch (error) {
210
- // If record not found, return null instead of throwing
211
- if (error instanceof RemoteDBError && error.status === 404) {
212
- return null
213
- }
214
- if (this.isNotAuthenticatedError(error)) {
215
- throw new NotAuthenticatedError('Sign in required to update items')
216
- }
217
- throw error
218
- }
219
- }
220
-
221
- /**
222
- * Delete a record by id
223
- * Requires authentication - throws NotAuthenticatedError if not signed in
224
- */
225
- async delete(id: string): Promise<boolean> {
226
- if (!id) {
227
- throw new Error('delete() requires an id')
228
- }
229
-
230
- try {
231
- await this.request<any>(
232
- 'DELETE',
233
- `${this.basePath}/${id}`
234
- )
235
- return true
236
- } catch (error) {
237
- // If record not found, return false instead of throwing
238
- if (error instanceof RemoteDBError && error.status === 404) {
239
- return false
240
- }
241
- if (this.isNotAuthenticatedError(error)) {
242
- throw new NotAuthenticatedError('Sign in required to delete items')
243
- }
244
- throw error
245
- }
246
- }
247
-
248
- /**
249
- * Get a single record by id
250
- * Returns null if not authenticated (graceful degradation for read operations)
251
- */
252
- async get(id: string): Promise<T | null> {
253
- if (!id) {
254
- throw new Error('get() requires an id')
255
- }
256
-
257
- try {
258
- // Use the API's id query parameter for efficient single-record fetch
259
- const result = await this.request<{ data: T[] }>(
260
- 'GET',
261
- `${this.basePath}?id=${id}`
262
- )
263
- return result.data?.[0] || null
264
- } catch (error) {
265
- // For get(), return null on any error (not found, not authenticated, etc.)
266
- if (this.isNotAuthenticatedError(error)) {
267
- this.log('Not authenticated - returning null for get()')
268
- }
269
- return null
270
- }
271
- }
272
-
273
- /**
274
- * Get all records in the collection
275
- * Returns empty array if not authenticated (graceful degradation for read operations)
276
- */
277
- async getAll(): Promise<T[]> {
278
- try {
279
- const result = await this.request<{ data: T[] }>(
280
- 'GET',
281
- this.basePath
282
- )
283
- return result.data || []
284
- } catch (error) {
285
- // If not authenticated, return empty array gracefully
286
- if (this.isNotAuthenticatedError(error)) {
287
- this.log('Not authenticated - returning empty array for getAll()')
288
- return []
289
- }
290
- throw error
291
- }
292
- }
293
-
294
- /**
295
- * Filter records using a predicate function
296
- * Note: This fetches all records and filters client-side
297
- * Returns empty array if not authenticated (graceful degradation for read operations)
298
- */
299
- async filter(fn: (item: T) => boolean): Promise<T[]> {
300
- const all = await this.getAll()
301
- return all.filter(fn)
302
- }
303
-
304
- /**
305
- * ref is not available for remote collections
306
- */
307
- ref = undefined
308
- }
@@ -1,40 +0,0 @@
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
-
@@ -1,7 +0,0 @@
1
- // Core DB exports
2
- export type { Collection, BasicDB, DBMode, RemoteDBConfig, GetTokenOptions } 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
-
@@ -1,140 +0,0 @@
1
- /**
2
- * Core DB types for Basic SDK
3
- * These interfaces are implemented by both SyncDB (Dexie-based) and RemoteDB (REST-based)
4
- */
5
-
6
- /**
7
- * Collection interface for CRUD operations on a table
8
- * All write operations return the full object (not just the id)
9
- */
10
- export interface Collection<T extends { id: string } = Record<string, any> & { id: string }> {
11
- /**
12
- * Add a new record to the collection
13
- * @param data - The data to add (without id, which will be generated)
14
- * @returns The created object with its generated id
15
- */
16
- add(data: Omit<T, 'id'>): Promise<T>
17
-
18
- /**
19
- * Put (upsert) a record - requires id
20
- * @param data - The full object including id
21
- * @returns The upserted object
22
- */
23
- put(data: T): Promise<T>
24
-
25
- /**
26
- * Update an existing record by id
27
- * @param id - The record id to update
28
- * @param data - Partial data to merge
29
- * @returns The updated object, or null if not found
30
- */
31
- update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T | null>
32
-
33
- /**
34
- * Delete a record by id
35
- * @param id - The record id to delete
36
- * @returns true if deleted, false if not found
37
- */
38
- delete(id: string): Promise<boolean>
39
-
40
- /**
41
- * Get a single record by id
42
- * @param id - The record id to fetch
43
- * @returns The object or null if not found
44
- */
45
- get(id: string): Promise<T | null>
46
-
47
- /**
48
- * Get all records in the collection
49
- * @returns Array of all objects
50
- */
51
- getAll(): Promise<T[]>
52
-
53
- /**
54
- * Filter records using a predicate function
55
- * @param fn - Filter function that returns true for matches
56
- * @returns Array of matching objects
57
- */
58
- filter(fn: (item: T) => boolean): Promise<T[]>
59
-
60
- /**
61
- * Direct access to underlying storage (optional)
62
- * For sync mode: Dexie table reference
63
- * For remote mode: undefined
64
- */
65
- ref?: any
66
- }
67
-
68
- /**
69
- * BasicDB interface - factory for creating collections
70
- */
71
- export interface BasicDB {
72
- /**
73
- * Get a collection by name
74
- * @param name - The table/collection name (must match schema)
75
- * @returns A Collection instance for CRUD operations
76
- */
77
- collection<T extends { id: string } = Record<string, any> & { id: string }>(name: string): Collection<T>
78
- }
79
-
80
- /**
81
- * Database mode - determines which implementation is used
82
- * - 'sync': Uses Dexie + WebSocket for local-first sync (default)
83
- * - 'remote': Uses REST API calls directly to server
84
- */
85
- export type DBMode = 'sync' | 'remote'
86
-
87
- /**
88
- * Auth error information passed to onAuthError callback
89
- */
90
- export interface AuthError {
91
- status: number
92
- message: string
93
- response?: any
94
- /** Classifies the error for UI display (e.g. "session expired" vs "forbidden") */
95
- errorType: 'expired' | 'forbidden' | 'revoked' | 'network' | 'unknown'
96
- /** True if this error occurred after a retry with a refreshed token */
97
- afterRetry: boolean
98
- }
99
-
100
- /**
101
- * Custom error class for Remote DB API errors
102
- * Includes HTTP status code for reliable error handling
103
- */
104
- export class RemoteDBError extends Error {
105
- status: number
106
- response?: any
107
-
108
- constructor(message: string, status: number, response?: any) {
109
- super(message)
110
- this.name = 'RemoteDBError'
111
- this.status = status
112
- this.response = response
113
- }
114
- }
115
-
116
- /**
117
- * Options for getToken (e.g. force refresh after 401)
118
- */
119
- export interface GetTokenOptions {
120
- /** When true, refresh the access token before returning (e.g. after server returned 401) */
121
- forceRefresh?: boolean
122
- }
123
-
124
- /**
125
- * Configuration for RemoteDB
126
- */
127
- export interface RemoteDBConfig {
128
- serverUrl: string
129
- projectId: string
130
- getToken: (options?: GetTokenOptions) => Promise<string>
131
- schema?: any
132
- /** Enable debug logging (default: false) */
133
- debug?: boolean
134
- /**
135
- * Optional callback when authentication fails (401 error after retry)
136
- * Use this to show login UI or redirect to sign-in
137
- */
138
- onAuthError?: (error: AuthError) => void
139
- }
140
-