@basictech/react 0.7.0-beta.5 → 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.
- package/.turbo/turbo-build.log +10 -10
- package/changelog.md +12 -0
- package/dist/index.d.mts +266 -13
- package/dist/index.d.ts +266 -13
- package/dist/index.js +645 -184
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +625 -172
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/readme.md +203 -209
- package/src/AuthContext.tsx +197 -54
- package/src/core/db/RemoteCollection.ts +294 -0
- package/src/core/db/RemoteDB.ts +40 -0
- package/src/core/db/index.ts +7 -0
- package/src/core/db/types.ts +128 -0
- package/src/index.ts +25 -9
- package/src/sync/index.ts +133 -54
- package/src/db.ts +0 -55
package/src/AuthContext.tsx
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
327
|
+
// Initialize the appropriate DB based on mode
|
|
328
|
+
if (dbMode === 'remote') {
|
|
329
|
+
initRemoteDb()
|
|
228
330
|
} else {
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -413,8 +525,14 @@ export function BasicProvider({
|
|
|
413
525
|
|
|
414
526
|
if (isExpired) {
|
|
415
527
|
log('token is expired - refreshing ...')
|
|
528
|
+
const refreshToken = token?.refresh_token
|
|
529
|
+
if (!refreshToken) {
|
|
530
|
+
log('Error: No refresh token available for expired token')
|
|
531
|
+
setIsAuthReady(true)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
416
534
|
try {
|
|
417
|
-
const newToken = await fetchToken(
|
|
535
|
+
const newToken = await fetchToken(refreshToken, true)
|
|
418
536
|
fetchUser(newToken?.access_token || '')
|
|
419
537
|
} catch (error) {
|
|
420
538
|
log('Failed to refresh token in checkToken:', error)
|
|
@@ -670,6 +788,13 @@ export function BasicProvider({
|
|
|
670
788
|
}
|
|
671
789
|
|
|
672
790
|
const fetchToken = async (codeOrRefreshToken: string, isRefreshToken: boolean = false): Promise<Token | null> => {
|
|
791
|
+
// Validate input
|
|
792
|
+
if (!codeOrRefreshToken || codeOrRefreshToken.trim() === '') {
|
|
793
|
+
const errorMsg = isRefreshToken ? 'Refresh token is empty or undefined' : 'Authorization code is empty or undefined'
|
|
794
|
+
log('Error:', errorMsg)
|
|
795
|
+
throw new Error(errorMsg)
|
|
796
|
+
}
|
|
797
|
+
|
|
673
798
|
// If this is a refresh token request and one is already in progress, return that promise
|
|
674
799
|
if (isRefreshToken && refreshPromiseRef.current) {
|
|
675
800
|
log('Reusing in-flight refresh token request')
|
|
@@ -816,27 +941,45 @@ export function BasicProvider({
|
|
|
816
941
|
return refreshPromise
|
|
817
942
|
}
|
|
818
943
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
944
|
+
// Get the current DB instance based on mode
|
|
945
|
+
const getCurrentDb = (): BasicDB => {
|
|
946
|
+
if (dbMode === 'remote') {
|
|
947
|
+
return remoteDbRef.current || noDb
|
|
822
948
|
}
|
|
823
|
-
|
|
949
|
+
return syncRef.current || noDb
|
|
950
|
+
}
|
|
824
951
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
+
}
|
|
839
980
|
|
|
981
|
+
return (
|
|
982
|
+
<BasicContext.Provider value={contextValue}>
|
|
840
983
|
{error && isDevMode() && <ErrorDisplay error={error} />}
|
|
841
984
|
{isReady && children}
|
|
842
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
|
+
|