@flowerforce/flowerbase-client 0.1.1-beta.2 → 0.1.1-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.
Files changed (45) hide show
  1. package/dist/app.d.ts +55 -10
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +322 -47
  4. package/dist/credentials.d.ts +1 -0
  5. package/dist/credentials.d.ts.map +1 -1
  6. package/dist/credentials.js +6 -0
  7. package/dist/functions.d.ts +4 -1
  8. package/dist/functions.d.ts.map +1 -1
  9. package/dist/functions.js +18 -1
  10. package/dist/http.d.ts +24 -4
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +121 -25
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/mongo.d.ts +1 -1
  17. package/dist/mongo.d.ts.map +1 -1
  18. package/dist/mongo.js +45 -4
  19. package/dist/session.d.ts +6 -0
  20. package/dist/session.d.ts.map +1 -1
  21. package/dist/session.js +52 -0
  22. package/dist/session.native.d.ts.map +1 -1
  23. package/dist/session.native.js +5 -10
  24. package/dist/types.d.ts +28 -4
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/user.d.ts +24 -4
  27. package/dist/user.d.ts.map +1 -1
  28. package/dist/user.js +103 -8
  29. package/package.json +12 -1
  30. package/src/__tests__/auth.test.ts +49 -0
  31. package/src/__tests__/compat.test.ts +10 -0
  32. package/src/__tests__/functions.test.ts +236 -0
  33. package/src/__tests__/mongo.test.ts +35 -0
  34. package/src/__tests__/session.test.ts +494 -0
  35. package/src/__tests__/watch.test.ts +74 -0
  36. package/src/app.ts +390 -63
  37. package/src/credentials.ts +7 -0
  38. package/src/functions.ts +27 -3
  39. package/src/http.ts +156 -27
  40. package/src/index.ts +1 -0
  41. package/src/mongo.ts +48 -4
  42. package/src/session.native.ts +2 -11
  43. package/src/session.ts +55 -0
  44. package/src/types.ts +34 -4
  45. package/src/user.ts +123 -12
package/dist/user.js CHANGED
@@ -5,26 +5,121 @@ const functions_1 = require("./functions");
5
5
  const mongo_1 = require("./mongo");
6
6
  class User {
7
7
  constructor(app, id) {
8
+ this.customData = {};
9
+ this._providerType = null;
10
+ this.listeners = new Set();
8
11
  this.app = app;
9
12
  this.id = id;
10
- this.functions = (0, functions_1.createFunctionsProxy)((name, args) => this.app.callFunction(name, args));
13
+ this.functions = (0, functions_1.createFunctionsProxy)((name, args) => this.app.callFunction(name, args, this.id), (name, args) => this.app.callFunctionStreaming(name, args, this.id));
14
+ this.customData = this.resolveCustomDataFromToken();
15
+ }
16
+ get state() {
17
+ if (!this.app.hasUser(this.id)) {
18
+ return 'removed';
19
+ }
20
+ return this.isLoggedIn ? 'active' : 'logged-out';
21
+ }
22
+ get isLoggedIn() {
23
+ return this.accessToken !== null && this.refreshToken !== null;
24
+ }
25
+ get providerType() {
26
+ return this._providerType;
27
+ }
28
+ get identities() {
29
+ return this.app.getProfileSnapshot(this.id)?.identities ?? [];
30
+ }
31
+ resolveCustomDataFromToken() {
32
+ const payload = this.decodeAccessTokenPayload();
33
+ if (!payload)
34
+ return {};
35
+ return ('user_data' in payload && payload.user_data && typeof payload.user_data === 'object'
36
+ ? payload.user_data
37
+ : 'userData' in payload && payload.userData && typeof payload.userData === 'object'
38
+ ? payload.userData
39
+ : 'custom_data' in payload && payload.custom_data && typeof payload.custom_data === 'object'
40
+ ? payload.custom_data
41
+ : {});
42
+ }
43
+ get accessToken() {
44
+ const session = this.app.getSession(this.id);
45
+ if (!session)
46
+ return null;
47
+ return session.accessToken;
48
+ }
49
+ get refreshToken() {
50
+ const session = this.app.getSession(this.id);
51
+ if (!session)
52
+ return null;
53
+ return session.refreshToken;
54
+ }
55
+ setProviderType(providerType) {
56
+ this._providerType = providerType;
57
+ }
58
+ decodeAccessTokenPayload() {
59
+ if (!this.accessToken)
60
+ return null;
61
+ const parts = this.accessToken.split('.');
62
+ if (parts.length < 2)
63
+ return null;
64
+ const base64Url = parts[1];
65
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
66
+ const decodeBase64 = (input) => {
67
+ if (typeof atob === 'function')
68
+ return atob(input);
69
+ const runtimeBuffer = globalThis.Buffer;
70
+ if (runtimeBuffer)
71
+ return runtimeBuffer.from(input, 'base64').toString('utf8');
72
+ return '';
73
+ };
74
+ try {
75
+ const decoded = decodeBase64(base64);
76
+ return JSON.parse(decoded);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
11
81
  }
12
82
  async logOut() {
13
- await this.app.logoutUser();
83
+ await this.app.logoutUser(this.id);
84
+ }
85
+ async callFunction(name, ...args) {
86
+ return this.app.callFunction(name, args, this.id);
14
87
  }
15
88
  async refreshAccessToken() {
16
- return this.app.refreshAccessToken();
89
+ const accessToken = await this.app.refreshAccessToken(this.id);
90
+ this.customData = this.resolveCustomDataFromToken();
91
+ return accessToken;
17
92
  }
18
93
  async refreshCustomData() {
19
- const profile = await this.app.getProfile();
94
+ const profile = await this.app.getProfile(this.id);
20
95
  this.profile = profile.data;
21
- return profile.custom_data || {};
96
+ this.customData = (profile.custom_data && typeof profile.custom_data === 'object'
97
+ ? profile.custom_data
98
+ : this.resolveCustomDataFromToken());
99
+ this.notifyListeners();
100
+ return this.customData;
22
101
  }
23
102
  mongoClient(serviceName) {
24
- if (serviceName !== 'mongodb-atlas') {
25
- throw new Error(`Unsupported service "${serviceName}"`);
103
+ return (0, mongo_1.createMongoClient)(this.app, serviceName, this.id);
104
+ }
105
+ addListener(callback) {
106
+ this.listeners.add(callback);
107
+ }
108
+ removeListener(callback) {
109
+ this.listeners.delete(callback);
110
+ }
111
+ removeAllListeners() {
112
+ this.listeners.clear();
113
+ }
114
+ notifyListeners() {
115
+ for (const callback of Array.from(this.listeners)) {
116
+ try {
117
+ callback();
118
+ }
119
+ catch {
120
+ // Listener failures should not break user lifecycle operations.
121
+ }
26
122
  }
27
- return (0, mongo_1.createMongoClient)(this.app);
28
123
  }
29
124
  }
30
125
  exports.User = User;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowerforce/flowerbase-client",
3
- "version": "0.1.1-beta.2",
3
+ "version": "0.1.1-beta.4",
4
4
  "description": "Client for Flowerbase",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,5 +26,16 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "bson": "^6.10.4"
29
+ },
30
+ "peerDependencies": {
31
+ "@react-native-async-storage/async-storage": "^2.2.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@react-native-async-storage/async-storage": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@react-native-async-storage/async-storage": "^2.2.0"
29
40
  }
30
41
  }
@@ -91,6 +91,26 @@ describe('flowerbase-client auth', () => {
91
91
  )
92
92
  })
93
93
 
94
+ it('logs in with custom jwt provider', async () => {
95
+ global.fetch = jest.fn().mockResolvedValue({
96
+ ok: true,
97
+ text: async () => JSON.stringify({
98
+ access_token: 'access',
99
+ refresh_token: 'refresh',
100
+ user_id: 'jwt-1'
101
+ })
102
+ }) as unknown as typeof fetch
103
+
104
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
105
+ const user = await app.logIn(Credentials.jwt('jwt-token'))
106
+
107
+ expect(user.id).toBe('jwt-1')
108
+ expect(global.fetch).toHaveBeenCalledWith(
109
+ 'http://localhost:3000/api/client/v2.0/app/my-app/auth/providers/custom-token/login',
110
+ expect.objectContaining({ method: 'POST' })
111
+ )
112
+ })
113
+
94
114
  it('supports register and reset endpoints', async () => {
95
115
  global.fetch = jest.fn().mockResolvedValue({
96
116
  ok: true,
@@ -126,6 +146,35 @@ describe('flowerbase-client auth', () => {
126
146
  )
127
147
  })
128
148
 
149
+ it('supports email/password confirmation endpoints', async () => {
150
+ global.fetch = jest.fn().mockResolvedValue({
151
+ ok: true,
152
+ text: async () => JSON.stringify({ status: 'ok' })
153
+ }) as unknown as typeof fetch
154
+
155
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
156
+
157
+ await app.emailPasswordAuth.confirmUser({ token: 't1', tokenId: 'tid1' })
158
+ await app.emailPasswordAuth.resendConfirmationEmail({ email: 'john@doe.com' })
159
+ await app.emailPasswordAuth.retryCustomConfirmation({ email: 'john@doe.com' })
160
+
161
+ expect(global.fetch).toHaveBeenNthCalledWith(
162
+ 1,
163
+ 'http://localhost:3000/api/client/v2.0/app/my-app/auth/providers/local-userpass/confirm',
164
+ expect.objectContaining({ method: 'POST' })
165
+ )
166
+ expect(global.fetch).toHaveBeenNthCalledWith(
167
+ 2,
168
+ 'http://localhost:3000/api/client/v2.0/app/my-app/auth/providers/local-userpass/confirm/send',
169
+ expect.objectContaining({ method: 'POST' })
170
+ )
171
+ expect(global.fetch).toHaveBeenNthCalledWith(
172
+ 3,
173
+ 'http://localhost:3000/api/client/v2.0/app/my-app/auth/providers/local-userpass/confirm/call',
174
+ expect.objectContaining({ method: 'POST' })
175
+ )
176
+ })
177
+
129
178
  it('calls session endpoint on app load when browser session exists', async () => {
130
179
  const storage = new Map<string, string>()
131
180
  storage.set(
@@ -3,10 +3,20 @@ import * as Flowerbase from '../index'
3
3
  describe('flowerbase-client compatibility surface', () => {
4
4
  it('exposes Realm-like symbols', () => {
5
5
  expect(typeof Flowerbase.App).toBe('function')
6
+ expect(typeof Flowerbase.App.getApp).toBe('function')
7
+ expect(Flowerbase.App.Credentials).toBe(Flowerbase.Credentials)
6
8
  expect(typeof Flowerbase.Credentials.emailPassword).toBe('function')
7
9
  expect(typeof Flowerbase.Credentials.anonymous).toBe('function')
8
10
  expect(typeof Flowerbase.Credentials.function).toBe('function')
11
+ expect(typeof Flowerbase.Credentials.jwt).toBe('function')
12
+ expect(typeof Flowerbase.MongoDBRealmError).toBe('function')
9
13
  expect(typeof Flowerbase.BSON.ObjectId).toBe('function')
10
14
  expect(Flowerbase.ObjectID).toBe(Flowerbase.ObjectId)
11
15
  })
16
+
17
+ it('returns singleton app for same id', () => {
18
+ const a1 = Flowerbase.App.getApp('singleton-app')
19
+ const a2 = Flowerbase.App.getApp('singleton-app')
20
+ expect(a1).toBe(a2)
21
+ })
12
22
  })
@@ -1,8 +1,18 @@
1
1
  import { App } from '../app'
2
2
  import { Credentials } from '../credentials'
3
+ import { FlowerbaseHttpError } from '../http'
3
4
 
4
5
  describe('flowerbase-client functions', () => {
5
6
  const originalFetch = global.fetch
7
+ const streamFromChunks = (chunks: string[]) =>
8
+ new ReadableStream<Uint8Array>({
9
+ start(controller) {
10
+ for (const chunk of chunks) {
11
+ controller.enqueue(new TextEncoder().encode(chunk))
12
+ }
13
+ controller.close()
14
+ }
15
+ })
6
16
 
7
17
  afterEach(() => {
8
18
  global.fetch = originalFetch
@@ -73,4 +83,230 @@ describe('flowerbase-client functions', () => {
73
83
 
74
84
  await expect(app.currentUser!.functions.explode()).rejects.toThrow('boom')
75
85
  })
86
+
87
+ it('supports functions.callFunction compatibility helper', async () => {
88
+ global.fetch = jest
89
+ .fn()
90
+ .mockResolvedValueOnce({
91
+ ok: true,
92
+ text: async () => JSON.stringify({
93
+ access_token: 'access',
94
+ refresh_token: 'refresh',
95
+ user_id: 'user-1'
96
+ })
97
+ })
98
+ .mockResolvedValueOnce({
99
+ ok: true,
100
+ text: async () => JSON.stringify({ access_token: 'access' })
101
+ })
102
+ .mockResolvedValueOnce({
103
+ ok: true,
104
+ text: async () => JSON.stringify({ result: 7 })
105
+ }) as unknown as typeof fetch
106
+
107
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
108
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
109
+
110
+ const result = await app.currentUser!.functions.callFunction('sum', 3, 4)
111
+ expect(result).toEqual({ result: 7 })
112
+ })
113
+
114
+ it('does not treat toJSON as a remote function name', async () => {
115
+ global.fetch = jest
116
+ .fn()
117
+ .mockResolvedValueOnce({
118
+ ok: true,
119
+ text: async () => JSON.stringify({
120
+ access_token: 'access',
121
+ refresh_token: 'refresh',
122
+ user_id: 'user-1'
123
+ })
124
+ })
125
+ .mockResolvedValueOnce({
126
+ ok: true,
127
+ text: async () => JSON.stringify({ access_token: 'access' })
128
+ }) as unknown as typeof fetch
129
+
130
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
131
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
132
+
133
+ expect(JSON.stringify(app.currentUser!.functions)).toBe('{}')
134
+ expect(global.fetch).toHaveBeenCalledTimes(2)
135
+ })
136
+
137
+ it('supports functions.callFunctionStreaming', async () => {
138
+ global.fetch = jest
139
+ .fn()
140
+ .mockResolvedValueOnce({
141
+ ok: true,
142
+ text: async () => JSON.stringify({
143
+ access_token: 'access',
144
+ refresh_token: 'refresh',
145
+ user_id: 'user-1'
146
+ })
147
+ })
148
+ .mockResolvedValueOnce({
149
+ ok: true,
150
+ text: async () => JSON.stringify({ access_token: 'access' })
151
+ })
152
+ .mockResolvedValueOnce({
153
+ ok: true,
154
+ body: streamFromChunks(['a', 'b'])
155
+ }) as unknown as typeof fetch
156
+
157
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
158
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
159
+
160
+ const stream = await app.currentUser!.functions.callFunctionStreaming('streamData')
161
+ const received: string[] = []
162
+ for await (const chunk of stream) {
163
+ received.push(new TextDecoder().decode(chunk))
164
+ }
165
+
166
+ expect(received).toEqual(['a', 'b'])
167
+ })
168
+
169
+ it('executes user-bound functions with that user session', async () => {
170
+ global.fetch = jest
171
+ .fn()
172
+ .mockResolvedValueOnce({
173
+ ok: true,
174
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
175
+ })
176
+ .mockResolvedValueOnce({
177
+ ok: true,
178
+ text: async () => JSON.stringify({ access_token: 'access-1' })
179
+ })
180
+ .mockResolvedValueOnce({
181
+ ok: true,
182
+ text: async () => JSON.stringify({ access_token: 'login-a2', refresh_token: 'refresh-2', user_id: 'user-2' })
183
+ })
184
+ .mockResolvedValueOnce({
185
+ ok: true,
186
+ text: async () => JSON.stringify({ access_token: 'access-2' })
187
+ })
188
+ .mockResolvedValue({
189
+ ok: true,
190
+ text: async () => JSON.stringify({ ok: true })
191
+ }) as unknown as typeof fetch
192
+
193
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
194
+ const user1 = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
195
+ await app.logIn(Credentials.anonymous())
196
+
197
+ await user1.functions.sum(1, 2)
198
+ const request = (global.fetch as jest.Mock).mock.calls[4][1]
199
+ expect(request.headers.Authorization).toBe('Bearer access-1')
200
+ })
201
+
202
+ it('retries streaming call when initial request returns 401', async () => {
203
+ const streamFromChunks = (chunks: string[]) =>
204
+ new ReadableStream<Uint8Array>({
205
+ start(controller) {
206
+ for (const chunk of chunks) {
207
+ controller.enqueue(new TextEncoder().encode(chunk))
208
+ }
209
+ controller.close()
210
+ }
211
+ })
212
+
213
+ global.fetch = jest
214
+ .fn()
215
+ .mockResolvedValueOnce({
216
+ ok: true,
217
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
218
+ })
219
+ .mockResolvedValueOnce({
220
+ ok: true,
221
+ text: async () => JSON.stringify({ access_token: 'access-1' })
222
+ })
223
+ .mockResolvedValueOnce({
224
+ ok: false,
225
+ status: 401,
226
+ statusText: 'Unauthorized',
227
+ text: async () => JSON.stringify({ error: 'Unauthorized', error_code: 'InvalidSession' })
228
+ })
229
+ .mockResolvedValueOnce({
230
+ ok: true,
231
+ text: async () => JSON.stringify({ access_token: 'access-2' })
232
+ })
233
+ .mockResolvedValueOnce({
234
+ ok: true,
235
+ body: streamFromChunks(['x', 'y'])
236
+ }) as unknown as typeof fetch
237
+
238
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
239
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
240
+
241
+ const stream = await app.currentUser!.functions.callFunctionStreaming('streamData')
242
+ const received: string[] = []
243
+ for await (const chunk of stream) {
244
+ received.push(new TextDecoder().decode(chunk))
245
+ }
246
+
247
+ expect(received).toEqual(['x', 'y'])
248
+ expect((global.fetch as jest.Mock).mock.calls[3][0]).toBe('http://localhost:3000/api/client/v2.0/auth/session')
249
+ })
250
+
251
+ it('retries streaming call when stream fails with 401 during iteration', async () => {
252
+ const streamWithAuthError = () =>
253
+ new ReadableStream<Uint8Array>({
254
+ start(controller) {
255
+ controller.enqueue(new TextEncoder().encode('a'))
256
+ controller.error(
257
+ new FlowerbaseHttpError({
258
+ method: 'POST',
259
+ url: 'http://localhost:3000/api/client/v2.0/app/my-app/functions/call',
260
+ statusCode: 401,
261
+ statusText: 'Unauthorized',
262
+ error: 'Expired token'
263
+ })
264
+ )
265
+ }
266
+ })
267
+
268
+ const streamFromChunks = (chunks: string[]) =>
269
+ new ReadableStream<Uint8Array>({
270
+ start(controller) {
271
+ for (const chunk of chunks) {
272
+ controller.enqueue(new TextEncoder().encode(chunk))
273
+ }
274
+ controller.close()
275
+ }
276
+ })
277
+
278
+ global.fetch = jest
279
+ .fn()
280
+ .mockResolvedValueOnce({
281
+ ok: true,
282
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
283
+ })
284
+ .mockResolvedValueOnce({
285
+ ok: true,
286
+ text: async () => JSON.stringify({ access_token: 'access-1' })
287
+ })
288
+ .mockResolvedValueOnce({
289
+ ok: true,
290
+ body: streamWithAuthError()
291
+ })
292
+ .mockResolvedValueOnce({
293
+ ok: true,
294
+ text: async () => JSON.stringify({ access_token: 'access-2' })
295
+ })
296
+ .mockResolvedValueOnce({
297
+ ok: true,
298
+ body: streamFromChunks(['b'])
299
+ }) as unknown as typeof fetch
300
+
301
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
302
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
303
+
304
+ const stream = await app.currentUser!.functions.callFunctionStreaming('streamData')
305
+ const received: string[] = []
306
+ for await (const chunk of stream) {
307
+ received.push(new TextDecoder().decode(chunk))
308
+ }
309
+
310
+ expect(received).toContain('b')
311
+ })
76
312
  })
@@ -45,4 +45,39 @@ describe('flowerbase-client mongo service wrapper', () => {
45
45
  expect(parsed.service).toBe('mongodb-atlas')
46
46
  expect(parsed.name).toBe('findOne')
47
47
  })
48
+
49
+ it('supports extended CRUD operations and custom service name', async () => {
50
+ global.fetch = jest
51
+ .fn()
52
+ .mockResolvedValueOnce({
53
+ ok: true,
54
+ text: async () => JSON.stringify({
55
+ access_token: 'access',
56
+ refresh_token: 'refresh',
57
+ user_id: 'user-1'
58
+ })
59
+ })
60
+ .mockResolvedValue({
61
+ ok: true,
62
+ text: async () => JSON.stringify({ ok: true })
63
+ }) as unknown as typeof fetch
64
+
65
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
66
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
67
+
68
+ const collection = app.currentUser!.mongoClient('my-service').db('testdb').collection('todos')
69
+
70
+ await collection.findOneAndUpdate({ done: false }, { $set: { done: true } })
71
+ await collection.findOneAndReplace({ done: true }, { done: true, title: 'done' })
72
+ await collection.findOneAndDelete({ done: true })
73
+ await collection.aggregate([{ $match: { done: true } }])
74
+ await collection.count({ done: true })
75
+ await collection.insertMany([{ title: 'A' }, { title: 'B' }])
76
+ await collection.deleteMany({ done: true })
77
+
78
+ const calls = (global.fetch as jest.Mock).mock.calls
79
+ const lastBody = JSON.parse(calls[calls.length - 1][1].body)
80
+ expect(lastBody.service).toBe('my-service')
81
+ expect(lastBody.name).toBe('deleteMany')
82
+ })
48
83
  })