@flowerforce/flowerbase-client 0.1.1-beta.2

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/LICENSE +3 -0
  3. package/README.md +198 -0
  4. package/dist/app.d.ts +40 -0
  5. package/dist/app.d.ts.map +1 -0
  6. package/dist/app.js +186 -0
  7. package/dist/bson.d.ts +8 -0
  8. package/dist/bson.d.ts.map +1 -0
  9. package/dist/bson.js +10 -0
  10. package/dist/credentials.d.ts +7 -0
  11. package/dist/credentials.d.ts.map +1 -0
  12. package/dist/credentials.js +24 -0
  13. package/dist/functions.d.ts +3 -0
  14. package/dist/functions.d.ts.map +1 -0
  15. package/dist/functions.js +30 -0
  16. package/dist/http.d.ts +15 -0
  17. package/dist/http.d.ts.map +1 -0
  18. package/dist/http.js +74 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +14 -0
  22. package/dist/mongo.d.ts +4 -0
  23. package/dist/mongo.d.ts.map +1 -0
  24. package/dist/mongo.js +61 -0
  25. package/dist/session.d.ts +12 -0
  26. package/dist/session.d.ts.map +1 -0
  27. package/dist/session.js +53 -0
  28. package/dist/session.native.d.ts +14 -0
  29. package/dist/session.native.d.ts.map +1 -0
  30. package/dist/session.native.js +81 -0
  31. package/dist/types.d.ts +73 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +2 -0
  34. package/dist/user.d.ts +17 -0
  35. package/dist/user.d.ts.map +1 -0
  36. package/dist/user.js +30 -0
  37. package/dist/watch.d.ts +3 -0
  38. package/dist/watch.d.ts.map +1 -0
  39. package/dist/watch.js +138 -0
  40. package/jest.config.ts +13 -0
  41. package/package.json +30 -0
  42. package/project.json +11 -0
  43. package/rollup.config.js +17 -0
  44. package/src/__tests__/auth.test.ts +164 -0
  45. package/src/__tests__/compat.test.ts +12 -0
  46. package/src/__tests__/functions.test.ts +76 -0
  47. package/src/__tests__/mongo.test.ts +48 -0
  48. package/src/__tests__/session.test.ts +103 -0
  49. package/src/__tests__/watch.test.ts +138 -0
  50. package/src/app.ts +235 -0
  51. package/src/bson.ts +6 -0
  52. package/src/credentials.ts +24 -0
  53. package/src/functions.ts +32 -0
  54. package/src/http.ts +92 -0
  55. package/src/index.ts +14 -0
  56. package/src/mongo.ts +63 -0
  57. package/src/session.native.ts +98 -0
  58. package/src/session.ts +59 -0
  59. package/src/types.ts +84 -0
  60. package/src/user.ts +39 -0
  61. package/src/watch.ts +150 -0
  62. package/tsconfig.json +34 -0
  63. package/tsconfig.spec.json +13 -0
package/src/app.ts ADDED
@@ -0,0 +1,235 @@
1
+ import { normalizeFunctionResponse } from './functions'
2
+ import { requestJson } from './http'
3
+ import { SessionManager } from './session'
4
+ import { AppConfig, CredentialsLike, FunctionCallPayload, ProfileData, SessionData } from './types'
5
+ import { User } from './user'
6
+
7
+ const API_PREFIX = '/api/client/v2.0'
8
+
9
+ type LoginResponse = {
10
+ access_token: string
11
+ refresh_token: string
12
+ user_id: string
13
+ }
14
+
15
+ type SessionResponse = {
16
+ access_token: string
17
+ }
18
+
19
+ export class App {
20
+ readonly id: string
21
+ readonly baseUrl: string
22
+ readonly timeout: number
23
+ private readonly sessionManager: SessionManager
24
+ currentUser: User | null = null
25
+ private readonly sessionBootstrapPromise: Promise<void>
26
+
27
+ emailPasswordAuth: {
28
+ registerUser: (input: { email: string; password: string }) => Promise<unknown>
29
+ sendResetPasswordEmail: (email: string) => Promise<unknown>
30
+ callResetPasswordFunction: (email: string, password: string, ...args: unknown[]) => Promise<unknown>
31
+ resetPassword: (input: { token: string; tokenId: string; password: string }) => Promise<unknown>
32
+ }
33
+
34
+ constructor(config: AppConfig) {
35
+ this.id = config.id
36
+ this.baseUrl = config.baseUrl.replace(/\/$/, '')
37
+ this.timeout = config.timeout ?? 10000
38
+ this.sessionManager = new SessionManager(this.id)
39
+
40
+ const session = this.sessionManager.get()
41
+ if (session?.userId) {
42
+ this.currentUser = new User(this, session.userId)
43
+ }
44
+ this.sessionBootstrapPromise = this.bootstrapSessionOnLoad()
45
+
46
+ this.emailPasswordAuth = {
47
+ registerUser: ({ email, password }) =>
48
+ this.postProvider('/local-userpass/register', { email, password }),
49
+ sendResetPasswordEmail: (email) =>
50
+ this.postProvider('/local-userpass/reset/send', { email }),
51
+ callResetPasswordFunction: (email, password, ...args) =>
52
+ this.postProvider('/local-userpass/reset/call', { email, password, arguments: args }),
53
+ resetPassword: ({ token, tokenId, password }) =>
54
+ this.postProvider('/local-userpass/reset', { token, tokenId, password })
55
+ }
56
+ }
57
+
58
+ private providerUrl(path: string) {
59
+ return `${this.baseUrl}${API_PREFIX}/app/${this.id}/auth/providers${path}`
60
+ }
61
+
62
+ private authUrl(path: string) {
63
+ return `${this.baseUrl}${API_PREFIX}/auth${path}`
64
+ }
65
+
66
+ private functionsUrl(path = '/call') {
67
+ return `${this.baseUrl}${API_PREFIX}/app/${this.id}/functions${path}`
68
+ }
69
+
70
+ private async createSession(refreshToken: string): Promise<SessionResponse> {
71
+ return requestJson<SessionResponse>({
72
+ url: this.authUrl('/session'),
73
+ method: 'POST',
74
+ bearerToken: refreshToken,
75
+ timeout: this.timeout
76
+ })
77
+ }
78
+
79
+ private async bootstrapSessionOnLoad(): Promise<void> {
80
+ const session = this.sessionManager.get()
81
+ if (!session || typeof localStorage === 'undefined') {
82
+ return
83
+ }
84
+
85
+ try {
86
+ const result = await this.createSession(session.refreshToken)
87
+ this.sessionManager.set({
88
+ ...session,
89
+ accessToken: result.access_token
90
+ })
91
+ } catch {
92
+ this.sessionManager.clear()
93
+ this.currentUser = null
94
+ }
95
+ }
96
+
97
+ private async ensureSessionBootstrapped() {
98
+ await this.sessionBootstrapPromise
99
+ }
100
+
101
+ private async setLoggedInUser(data: LoginResponse, profileEmail?: string) {
102
+ const sessionResult = await this.createSession(data.refresh_token)
103
+ const session: SessionData = {
104
+ accessToken: sessionResult.access_token,
105
+ refreshToken: data.refresh_token,
106
+ userId: data.user_id
107
+ }
108
+ this.sessionManager.set(session)
109
+ this.currentUser = new User(this, data.user_id)
110
+ if (profileEmail) {
111
+ this.currentUser.profile = { email: profileEmail }
112
+ }
113
+ return this.currentUser
114
+ }
115
+
116
+ async logIn(credentials: CredentialsLike) {
117
+ if (credentials.provider === 'local-userpass') {
118
+ const result = await this.postProvider<LoginResponse>('/local-userpass/login', {
119
+ username: credentials.email,
120
+ password: credentials.password
121
+ })
122
+ return this.setLoggedInUser(result, credentials.email)
123
+ }
124
+
125
+ if (credentials.provider === 'anon-user') {
126
+ const result = await this.postProvider<LoginResponse>('/anon-user/login', {})
127
+ return this.setLoggedInUser(result)
128
+ }
129
+
130
+ const result = await this.postProvider<LoginResponse>('/custom-function/login', credentials.payload)
131
+ return this.setLoggedInUser(result)
132
+ }
133
+
134
+ getSessionOrThrow() {
135
+ const session = this.sessionManager.get()
136
+ if (!session) {
137
+ throw new Error('User is not authenticated')
138
+ }
139
+ return session
140
+ }
141
+
142
+ async postProvider<T = unknown>(path: string, body: unknown): Promise<T> {
143
+ return requestJson<T>({
144
+ url: this.providerUrl(path),
145
+ method: 'POST',
146
+ body,
147
+ timeout: this.timeout
148
+ })
149
+ }
150
+
151
+ async callFunction(name: string, args: unknown[]) {
152
+ await this.ensureSessionBootstrapped()
153
+ const session = this.getSessionOrThrow()
154
+ const payload: FunctionCallPayload = {
155
+ name,
156
+ arguments: args
157
+ }
158
+
159
+ const result = await requestJson<unknown>({
160
+ url: this.functionsUrl('/call'),
161
+ method: 'POST',
162
+ body: payload,
163
+ bearerToken: session.accessToken,
164
+ timeout: this.timeout
165
+ })
166
+
167
+ return normalizeFunctionResponse(result)
168
+ }
169
+
170
+ async callService(name: string, args: unknown[]) {
171
+ await this.ensureSessionBootstrapped()
172
+ const session = this.getSessionOrThrow()
173
+ const payload: FunctionCallPayload = {
174
+ name,
175
+ service: 'mongodb-atlas',
176
+ arguments: args
177
+ }
178
+
179
+ return requestJson<unknown>({
180
+ url: this.functionsUrl('/call'),
181
+ method: 'POST',
182
+ body: payload,
183
+ bearerToken: session.accessToken,
184
+ timeout: this.timeout
185
+ })
186
+ }
187
+
188
+ async getProfile(): Promise<ProfileData> {
189
+ await this.ensureSessionBootstrapped()
190
+ const session = this.getSessionOrThrow()
191
+ return requestJson<ProfileData>({
192
+ url: this.authUrl('/profile'),
193
+ method: 'GET',
194
+ bearerToken: session.accessToken,
195
+ timeout: this.timeout
196
+ })
197
+ }
198
+
199
+ async refreshAccessToken() {
200
+ await this.ensureSessionBootstrapped()
201
+ const session = this.getSessionOrThrow()
202
+
203
+ try {
204
+ const result = await this.createSession(session.refreshToken)
205
+
206
+ this.sessionManager.set({
207
+ ...session,
208
+ accessToken: result.access_token
209
+ })
210
+
211
+ return result.access_token
212
+ } catch (error) {
213
+ this.sessionManager.clear()
214
+ this.currentUser = null
215
+ throw error
216
+ }
217
+ }
218
+
219
+ async logoutUser() {
220
+ const session = this.sessionManager.get()
221
+ try {
222
+ if (session) {
223
+ await requestJson({
224
+ url: this.authUrl('/session'),
225
+ method: 'DELETE',
226
+ bearerToken: session.refreshToken,
227
+ timeout: this.timeout
228
+ })
229
+ }
230
+ } finally {
231
+ this.sessionManager.clear()
232
+ this.currentUser = null
233
+ }
234
+ }
235
+ }
package/src/bson.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { EJSON, ObjectId, BSON as RawBSON } from 'bson'
2
+
3
+ const ObjectID = ObjectId
4
+ const BSON = Object.assign({}, RawBSON, { ObjectId, ObjectID })
5
+
6
+ export { BSON, EJSON, ObjectId, ObjectID }
@@ -0,0 +1,24 @@
1
+ import { CredentialsLike } from './types'
2
+
3
+ export class Credentials {
4
+ static emailPassword(email: string, password: string): CredentialsLike {
5
+ return {
6
+ provider: 'local-userpass',
7
+ email,
8
+ password
9
+ }
10
+ }
11
+
12
+ static anonymous(): CredentialsLike {
13
+ return {
14
+ provider: 'anon-user'
15
+ }
16
+ }
17
+
18
+ static function(payload: Record<string, unknown>): CredentialsLike {
19
+ return {
20
+ provider: 'custom-function',
21
+ payload
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,32 @@
1
+ import { EJSON } from './bson'
2
+
3
+ const deserialize = <T>(value: T): T => {
4
+ if (!value || typeof value !== 'object') return value
5
+ return EJSON.deserialize(value as Record<string, unknown>) as T
6
+ }
7
+
8
+ export const normalizeFunctionResponse = (value: unknown) => {
9
+ if (typeof value === 'string') {
10
+ try {
11
+ const parsed = JSON.parse(value)
12
+ return deserialize(parsed)
13
+ } catch {
14
+ return value
15
+ }
16
+ }
17
+
18
+ return deserialize(value)
19
+ }
20
+
21
+ export const createFunctionsProxy = (
22
+ callFunction: (name: string, args: unknown[]) => Promise<unknown>
23
+ ): Record<string, (...args: unknown[]) => Promise<unknown>> =>
24
+ new Proxy(
25
+ {},
26
+ {
27
+ get: (_, key) => {
28
+ if (typeof key !== 'string') return undefined
29
+ return (...args: unknown[]) => callFunction(key, args)
30
+ }
31
+ }
32
+ ) as Record<string, (...args: unknown[]) => Promise<unknown>>
package/src/http.ts ADDED
@@ -0,0 +1,92 @@
1
+ export class FlowerbaseHttpError extends Error {
2
+ status: number
3
+ payload?: unknown
4
+
5
+ constructor(message: string, status: number, payload?: unknown) {
6
+ super(message)
7
+ this.name = 'FlowerbaseHttpError'
8
+ this.status = status
9
+ this.payload = payload
10
+ }
11
+ }
12
+
13
+ type RequestParams = {
14
+ url: string
15
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
16
+ body?: unknown
17
+ bearerToken?: string
18
+ timeout?: number
19
+ }
20
+
21
+ const parseBody = async (response: Response) => {
22
+ const text = await response.text()
23
+ if (!text) return null
24
+
25
+ try {
26
+ return JSON.parse(text)
27
+ } catch {
28
+ return text
29
+ }
30
+ }
31
+
32
+ const timeoutSignal = (timeout = 10000) => {
33
+ const controller = new AbortController()
34
+ const timer = setTimeout(() => controller.abort(), timeout)
35
+ return {
36
+ signal: controller.signal,
37
+ clear: () => clearTimeout(timer)
38
+ }
39
+ }
40
+
41
+ export const requestJson = async <T = unknown>({
42
+ url,
43
+ method = 'GET',
44
+ body,
45
+ bearerToken,
46
+ timeout
47
+ }: RequestParams): Promise<T> => {
48
+ const { signal, clear } = timeoutSignal(timeout)
49
+
50
+ try {
51
+ const response = await fetch(url, {
52
+ method,
53
+ headers: {
54
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
55
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {})
56
+ },
57
+ body: body !== undefined ? JSON.stringify(body) : undefined,
58
+ signal
59
+ })
60
+
61
+ const payload = await parseBody(response)
62
+
63
+ if (!response.ok) {
64
+ let parsedErrorMessage: string | null = null
65
+ if (payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string') {
66
+ try {
67
+ const parsed = JSON.parse(payload.error)
68
+ if (parsed && typeof parsed === 'object' && 'message' in parsed && typeof parsed.message === 'string') {
69
+ parsedErrorMessage = parsed.message
70
+ }
71
+ } catch {
72
+ parsedErrorMessage = null
73
+ }
74
+ }
75
+
76
+ const message =
77
+ parsedErrorMessage ||
78
+ (payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string'
79
+ ? payload.message
80
+ : null) ||
81
+ (payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
82
+ ? payload.error
83
+ : null) ||
84
+ `HTTP ${response.status}`
85
+ throw new FlowerbaseHttpError(message, response.status, payload)
86
+ }
87
+
88
+ return payload as T
89
+ } finally {
90
+ clear()
91
+ }
92
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { BSON, EJSON, ObjectId, ObjectID } from './bson'
2
+
3
+ export { App } from './app'
4
+ export { User } from './user'
5
+ export { Credentials } from './credentials'
6
+ export { BSON, EJSON, ObjectId, ObjectID }
7
+ export type {
8
+ AppConfig,
9
+ CredentialsLike,
10
+ UserLike,
11
+ MongoClientLike,
12
+ CollectionLike,
13
+ WatchAsyncIterator
14
+ } from './types'
package/src/mongo.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { App } from './app'
2
+ import { EJSON } from './bson'
3
+ import { CollectionLike, MongoClientLike } from './types'
4
+ import { createWatchIterator } from './watch'
5
+
6
+ const serialize = (value: unknown) => EJSON.serialize(value, { relaxed: false })
7
+ const deserialize = <T>(value: T): T => {
8
+ if (!value || typeof value !== 'object') return value
9
+ return EJSON.deserialize(value as Record<string, unknown>) as T
10
+ }
11
+
12
+ const mapResult = (value: unknown) => {
13
+ if (typeof value === 'string') {
14
+ try {
15
+ return deserialize(JSON.parse(value))
16
+ } catch {
17
+ return value
18
+ }
19
+ }
20
+ return deserialize(value)
21
+ }
22
+
23
+ export const createMongoClient = (app: App): MongoClientLike => ({
24
+ db: (database: string) => ({
25
+ collection: (collection: string): CollectionLike => {
26
+ const callService = async (name: string, args: unknown[]) => {
27
+ const result = await app.callService(name, [
28
+ {
29
+ database,
30
+ collection,
31
+ ...serialize(args[0] ?? {}),
32
+ ...(args[1] !== undefined ? { options: serialize(args[1]) } : {})
33
+ }
34
+ ])
35
+ return mapResult(result)
36
+ }
37
+
38
+ return {
39
+ find: (query = {}, options = {}) => callService('find', [{ query, options }]),
40
+ findOne: (query = {}, options = {}) => callService('findOne', [{ query, options }]),
41
+ insertOne: (document, options = {}) => callService('insertOne', [{ document, options }]),
42
+ updateOne: (filter, update, options = {}) =>
43
+ callService('updateOne', [{ filter, update, options }]),
44
+ updateMany: (filter, update, options = {}) =>
45
+ callService('updateMany', [{ filter, update, options }]),
46
+ deleteOne: (filter, options = {}) => callService('deleteOne', [{ query: filter, options }]),
47
+ watch: (pipeline = [], options = {}) => {
48
+ const session = app.getSessionOrThrow()
49
+ return createWatchIterator({
50
+ appId: app.id,
51
+ baseUrl: app.baseUrl,
52
+ accessToken: session.accessToken,
53
+ database,
54
+ collection,
55
+ pipeline,
56
+ options,
57
+ timeout: app.timeout
58
+ })
59
+ }
60
+ }
61
+ }
62
+ })
63
+ })
@@ -0,0 +1,98 @@
1
+ import { SessionData } from './types'
2
+
3
+ type StorageLike = {
4
+ getItem: (key: string) => Promise<string | null>
5
+ setItem: (key: string, value: string) => Promise<void>
6
+ removeItem: (key: string) => Promise<void>
7
+ }
8
+
9
+ const memoryStore = new Map<string, string>()
10
+
11
+ const getAsyncStorage = (): StorageLike | null => {
12
+ try {
13
+ const req = (0, eval)('require') as (name: string) => unknown
14
+ const asyncStorageModule = req('@react-native-async-storage/async-storage') as {
15
+ default?: StorageLike
16
+ }
17
+ return asyncStorageModule?.default ?? null
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
23
+ const parseSession = (raw: string | null): SessionData | null => {
24
+ if (!raw) return null
25
+ try {
26
+ return JSON.parse(raw) as SessionData
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ export class SessionManager {
33
+ private readonly key: string
34
+ private session: SessionData | null = null
35
+ private readonly asyncStorage = getAsyncStorage()
36
+ private hydrationPromise: Promise<void> | null = null
37
+
38
+ constructor(appId: string) {
39
+ this.key = `flowerbase:${appId}:session`
40
+ this.session = this.load()
41
+ void this.hydrateFromAsyncStorage()
42
+ }
43
+
44
+ private hydrateFromAsyncStorage() {
45
+ if (!this.asyncStorage) {
46
+ return Promise.resolve()
47
+ }
48
+
49
+ if (this.hydrationPromise) {
50
+ return this.hydrationPromise
51
+ }
52
+
53
+ this.hydrationPromise = this.asyncStorage
54
+ .getItem(this.key)
55
+ .then((raw) => {
56
+ const parsed = parseSession(raw)
57
+ if (!parsed) return
58
+ this.session = parsed
59
+ memoryStore.set(this.key, JSON.stringify(parsed))
60
+ })
61
+ .catch(() => {
62
+ // Ignore storage read failures and keep memory fallback.
63
+ })
64
+
65
+ return this.hydrationPromise
66
+ }
67
+
68
+ load(): SessionData | null {
69
+ return parseSession(memoryStore.get(this.key) ?? null)
70
+ }
71
+
72
+ get() {
73
+ return this.session
74
+ }
75
+
76
+ set(session: SessionData) {
77
+ this.session = session
78
+ const raw = JSON.stringify(session)
79
+ memoryStore.set(this.key, raw)
80
+
81
+ if (this.asyncStorage) {
82
+ void this.asyncStorage.setItem(this.key, raw).catch(() => {
83
+ // Ignore write failures and keep memory fallback.
84
+ })
85
+ }
86
+ }
87
+
88
+ clear() {
89
+ this.session = null
90
+ memoryStore.delete(this.key)
91
+
92
+ if (this.asyncStorage) {
93
+ void this.asyncStorage.removeItem(this.key).catch(() => {
94
+ // Ignore delete failures and keep memory fallback.
95
+ })
96
+ }
97
+ }
98
+ }
package/src/session.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { SessionData } from './types'
2
+
3
+ const memoryStore = new Map<string, string>()
4
+
5
+ const getStorage = () => {
6
+ if (typeof localStorage !== 'undefined') {
7
+ return {
8
+ getItem: (key: string) => localStorage.getItem(key),
9
+ setItem: (key: string, value: string) => localStorage.setItem(key, value),
10
+ removeItem: (key: string) => localStorage.removeItem(key)
11
+ }
12
+ }
13
+
14
+ return {
15
+ getItem: (key: string) => memoryStore.get(key) ?? null,
16
+ setItem: (key: string, value: string) => {
17
+ memoryStore.set(key, value)
18
+ },
19
+ removeItem: (key: string) => {
20
+ memoryStore.delete(key)
21
+ }
22
+ }
23
+ }
24
+
25
+ export class SessionManager {
26
+ private readonly key: string
27
+ private readonly storage = getStorage()
28
+ private session: SessionData | null = null
29
+
30
+ constructor(appId: string) {
31
+ this.key = `flowerbase:${appId}:session`
32
+ this.session = this.load()
33
+ }
34
+
35
+ load(): SessionData | null {
36
+ const raw = this.storage.getItem(this.key)
37
+ if (!raw) return null
38
+
39
+ try {
40
+ return JSON.parse(raw) as SessionData
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ get() {
47
+ return this.session
48
+ }
49
+
50
+ set(session: SessionData) {
51
+ this.session = session
52
+ this.storage.setItem(this.key, JSON.stringify(session))
53
+ }
54
+
55
+ clear() {
56
+ this.session = null
57
+ this.storage.removeItem(this.key)
58
+ }
59
+ }