@fluxbase/sdk-react 2026.1.22 → 2026.2.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.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Tests for database query hooks
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { renderHook, waitFor, act } from '@testing-library/react';
7
+ import {
8
+ useFluxbaseQuery,
9
+ useTable,
10
+ useInsert,
11
+ useUpdate,
12
+ useUpsert,
13
+ useDelete,
14
+ } from './use-query';
15
+ import { createMockClient, createWrapper, createTestQueryClient } from './test-utils';
16
+
17
+ describe('useFluxbaseQuery', () => {
18
+ it('should execute query and return data', async () => {
19
+ const mockData = [{ id: 1, name: 'Test' }];
20
+ const executeMock = vi.fn().mockResolvedValue({ data: mockData, error: null });
21
+ const fromMock = vi.fn().mockReturnValue({
22
+ select: vi.fn().mockReturnThis(),
23
+ execute: executeMock,
24
+ });
25
+ const client = createMockClient({ from: fromMock } as any);
26
+
27
+ const { result } = renderHook(
28
+ () => useFluxbaseQuery((client) => client.from('products').select('*'), { queryKey: ['products'] }),
29
+ { wrapper: createWrapper(client) }
30
+ );
31
+
32
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
33
+ expect(result.current.data).toEqual(mockData);
34
+ });
35
+
36
+ it('should throw error when query fails', async () => {
37
+ const error = new Error('Query failed');
38
+ const executeMock = vi.fn().mockResolvedValue({ data: null, error });
39
+ const fromMock = vi.fn().mockReturnValue({
40
+ select: vi.fn().mockReturnThis(),
41
+ execute: executeMock,
42
+ });
43
+ const client = createMockClient({ from: fromMock } as any);
44
+
45
+ const { result } = renderHook(
46
+ () => useFluxbaseQuery((client) => client.from('products').select('*'), { queryKey: ['products'] }),
47
+ { wrapper: createWrapper(client) }
48
+ );
49
+
50
+ await waitFor(() => expect(result.current.isError).toBe(true));
51
+ expect(result.current.error).toBe(error);
52
+ });
53
+
54
+ it('should handle single item response', async () => {
55
+ const mockData = { id: 1, name: 'Test' };
56
+ const executeMock = vi.fn().mockResolvedValue({ data: mockData, error: null });
57
+ const fromMock = vi.fn().mockReturnValue({
58
+ select: vi.fn().mockReturnThis(),
59
+ single: vi.fn().mockReturnThis(),
60
+ execute: executeMock,
61
+ });
62
+ const client = createMockClient({ from: fromMock } as any);
63
+
64
+ const { result } = renderHook(
65
+ () => useFluxbaseQuery((client) => client.from('products').select('*'), { queryKey: ['product'] }),
66
+ { wrapper: createWrapper(client) }
67
+ );
68
+
69
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
70
+ expect(result.current.data).toEqual([mockData]);
71
+ });
72
+
73
+ it('should handle null response', async () => {
74
+ const executeMock = vi.fn().mockResolvedValue({ data: null, error: null });
75
+ const fromMock = vi.fn().mockReturnValue({
76
+ select: vi.fn().mockReturnThis(),
77
+ execute: executeMock,
78
+ });
79
+ const client = createMockClient({ from: fromMock } as any);
80
+
81
+ const { result } = renderHook(
82
+ () => useFluxbaseQuery((client) => client.from('products').select('*'), { queryKey: ['products'] }),
83
+ { wrapper: createWrapper(client) }
84
+ );
85
+
86
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
87
+ expect(result.current.data).toEqual([]);
88
+ });
89
+
90
+ it('should generate query key from function when not provided', async () => {
91
+ const mockData = [{ id: 1 }];
92
+ const executeMock = vi.fn().mockResolvedValue({ data: mockData, error: null });
93
+ const fromMock = vi.fn().mockReturnValue({
94
+ select: vi.fn().mockReturnThis(),
95
+ execute: executeMock,
96
+ });
97
+ const client = createMockClient({ from: fromMock } as any);
98
+
99
+ const buildQuery = (client: any) => client.from('test').select('*');
100
+ const { result } = renderHook(
101
+ () => useFluxbaseQuery(buildQuery),
102
+ { wrapper: createWrapper(client) }
103
+ );
104
+
105
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
106
+ expect(result.current.data).toEqual(mockData);
107
+ });
108
+ });
109
+
110
+ describe('useTable', () => {
111
+ it('should query table with builder function', async () => {
112
+ const mockData = [{ id: 1, name: 'Test' }];
113
+ const executeMock = vi.fn().mockResolvedValue({ data: mockData, error: null });
114
+ const fromMock = vi.fn().mockReturnValue({
115
+ select: vi.fn().mockReturnThis(),
116
+ eq: vi.fn().mockReturnThis(),
117
+ execute: executeMock,
118
+ });
119
+ const client = createMockClient({ from: fromMock } as any);
120
+
121
+ const { result } = renderHook(
122
+ () => useTable('products', (q) => q.select('*').eq('active', true)),
123
+ { wrapper: createWrapper(client) }
124
+ );
125
+
126
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
127
+ expect(result.current.data).toEqual(mockData);
128
+ expect(fromMock).toHaveBeenCalledWith('products');
129
+ });
130
+
131
+ it('should query table without builder function', async () => {
132
+ const mockData = [{ id: 1 }];
133
+ const executeMock = vi.fn().mockResolvedValue({ data: mockData, error: null });
134
+ const fromMock = vi.fn().mockReturnValue({
135
+ execute: executeMock,
136
+ });
137
+ const client = createMockClient({ from: fromMock } as any);
138
+
139
+ const { result } = renderHook(
140
+ () => useTable('products'),
141
+ { wrapper: createWrapper(client) }
142
+ );
143
+
144
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
145
+ expect(result.current.data).toEqual(mockData);
146
+ });
147
+
148
+ it('should support custom query key', async () => {
149
+ const mockData = [{ id: 1 }];
150
+ const executeMock = vi.fn().mockResolvedValue({ data: mockData, error: null });
151
+ const fromMock = vi.fn().mockReturnValue({
152
+ execute: executeMock,
153
+ });
154
+ const client = createMockClient({ from: fromMock } as any);
155
+
156
+ const queryClient = createTestQueryClient();
157
+ const { result } = renderHook(
158
+ () => useTable('products', undefined, { queryKey: ['custom', 'key'] }),
159
+ { wrapper: createWrapper(client, queryClient) }
160
+ );
161
+
162
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
163
+ expect(queryClient.getQueryData(['custom', 'key'])).toEqual(mockData);
164
+ });
165
+ });
166
+
167
+ describe('useInsert', () => {
168
+ it('should insert data and invalidate queries', async () => {
169
+ const mockResult = { id: 1, name: 'New Item' };
170
+ const insertMock = vi.fn().mockResolvedValue({ data: mockResult, error: null });
171
+ const fromMock = vi.fn().mockReturnValue({
172
+ insert: insertMock,
173
+ });
174
+ const client = createMockClient({ from: fromMock } as any);
175
+
176
+ const queryClient = createTestQueryClient();
177
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
178
+
179
+ const { result } = renderHook(() => useInsert('products'), {
180
+ wrapper: createWrapper(client, queryClient),
181
+ });
182
+
183
+ await act(async () => {
184
+ await result.current.mutateAsync({ name: 'New Item' });
185
+ });
186
+
187
+ expect(fromMock).toHaveBeenCalledWith('products');
188
+ expect(insertMock).toHaveBeenCalledWith({ name: 'New Item' });
189
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'table', 'products'] });
190
+ });
191
+
192
+ it('should throw error on insert failure', async () => {
193
+ const error = new Error('Insert failed');
194
+ const insertMock = vi.fn().mockResolvedValue({ data: null, error });
195
+ const fromMock = vi.fn().mockReturnValue({
196
+ insert: insertMock,
197
+ });
198
+ const client = createMockClient({ from: fromMock } as any);
199
+
200
+ const { result } = renderHook(() => useInsert('products'), {
201
+ wrapper: createWrapper(client),
202
+ });
203
+
204
+ await expect(act(async () => {
205
+ await result.current.mutateAsync({ name: 'New Item' });
206
+ })).rejects.toThrow('Insert failed');
207
+ });
208
+ });
209
+
210
+ describe('useUpdate', () => {
211
+ it('should update data and invalidate queries', async () => {
212
+ const mockResult = { id: 1, name: 'Updated' };
213
+ const updateMock = vi.fn().mockResolvedValue({ data: mockResult, error: null });
214
+ const eqMock = vi.fn().mockReturnThis();
215
+ const fromMock = vi.fn().mockReturnValue({
216
+ eq: eqMock,
217
+ update: updateMock,
218
+ });
219
+ const client = createMockClient({ from: fromMock } as any);
220
+
221
+ const queryClient = createTestQueryClient();
222
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
223
+
224
+ const { result } = renderHook(() => useUpdate('products'), {
225
+ wrapper: createWrapper(client, queryClient),
226
+ });
227
+
228
+ await act(async () => {
229
+ await result.current.mutateAsync({
230
+ data: { name: 'Updated' },
231
+ buildQuery: (q) => q.eq('id', 1),
232
+ });
233
+ });
234
+
235
+ expect(fromMock).toHaveBeenCalledWith('products');
236
+ expect(updateMock).toHaveBeenCalledWith({ name: 'Updated' });
237
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'table', 'products'] });
238
+ });
239
+
240
+ it('should throw error on update failure', async () => {
241
+ const error = new Error('Update failed');
242
+ const updateMock = vi.fn().mockResolvedValue({ data: null, error });
243
+ const fromMock = vi.fn().mockReturnValue({
244
+ eq: vi.fn().mockReturnThis(),
245
+ update: updateMock,
246
+ });
247
+ const client = createMockClient({ from: fromMock } as any);
248
+
249
+ const { result } = renderHook(() => useUpdate('products'), {
250
+ wrapper: createWrapper(client),
251
+ });
252
+
253
+ await expect(act(async () => {
254
+ await result.current.mutateAsync({
255
+ data: { name: 'Updated' },
256
+ buildQuery: (q) => q.eq('id', 1),
257
+ });
258
+ })).rejects.toThrow('Update failed');
259
+ });
260
+ });
261
+
262
+ describe('useUpsert', () => {
263
+ it('should upsert data and invalidate queries', async () => {
264
+ const mockResult = { id: 1, name: 'Upserted' };
265
+ const upsertMock = vi.fn().mockResolvedValue({ data: mockResult, error: null });
266
+ const fromMock = vi.fn().mockReturnValue({
267
+ upsert: upsertMock,
268
+ });
269
+ const client = createMockClient({ from: fromMock } as any);
270
+
271
+ const queryClient = createTestQueryClient();
272
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
273
+
274
+ const { result } = renderHook(() => useUpsert('products'), {
275
+ wrapper: createWrapper(client, queryClient),
276
+ });
277
+
278
+ await act(async () => {
279
+ await result.current.mutateAsync({ id: 1, name: 'Upserted' });
280
+ });
281
+
282
+ expect(fromMock).toHaveBeenCalledWith('products');
283
+ expect(upsertMock).toHaveBeenCalledWith({ id: 1, name: 'Upserted' });
284
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'table', 'products'] });
285
+ });
286
+
287
+ it('should throw error on upsert failure', async () => {
288
+ const error = new Error('Upsert failed');
289
+ const upsertMock = vi.fn().mockResolvedValue({ data: null, error });
290
+ const fromMock = vi.fn().mockReturnValue({
291
+ upsert: upsertMock,
292
+ });
293
+ const client = createMockClient({ from: fromMock } as any);
294
+
295
+ const { result } = renderHook(() => useUpsert('products'), {
296
+ wrapper: createWrapper(client),
297
+ });
298
+
299
+ await expect(act(async () => {
300
+ await result.current.mutateAsync({ id: 1, name: 'Upserted' });
301
+ })).rejects.toThrow('Upsert failed');
302
+ });
303
+ });
304
+
305
+ describe('useDelete', () => {
306
+ it('should delete data and invalidate queries', async () => {
307
+ const deleteMock = vi.fn().mockResolvedValue({ data: null, error: null });
308
+ const eqMock = vi.fn().mockReturnThis();
309
+ const fromMock = vi.fn().mockReturnValue({
310
+ eq: eqMock,
311
+ delete: deleteMock,
312
+ });
313
+ const client = createMockClient({ from: fromMock } as any);
314
+
315
+ const queryClient = createTestQueryClient();
316
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
317
+
318
+ const { result } = renderHook(() => useDelete('products'), {
319
+ wrapper: createWrapper(client, queryClient),
320
+ });
321
+
322
+ await act(async () => {
323
+ await result.current.mutateAsync((q) => q.eq('id', 1));
324
+ });
325
+
326
+ expect(fromMock).toHaveBeenCalledWith('products');
327
+ expect(deleteMock).toHaveBeenCalled();
328
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'table', 'products'] });
329
+ });
330
+
331
+ it('should throw error on delete failure', async () => {
332
+ const error = new Error('Delete failed');
333
+ const deleteMock = vi.fn().mockResolvedValue({ error });
334
+ const fromMock = vi.fn().mockReturnValue({
335
+ eq: vi.fn().mockReturnThis(),
336
+ delete: deleteMock,
337
+ });
338
+ const client = createMockClient({ from: fromMock } as any);
339
+
340
+ const { result } = renderHook(() => useDelete('products'), {
341
+ wrapper: createWrapper(client),
342
+ });
343
+
344
+ await expect(act(async () => {
345
+ await result.current.mutateAsync((q) => q.eq('id', 1));
346
+ })).rejects.toThrow('Delete failed');
347
+ });
348
+ });
package/src/use-query.ts CHANGED
@@ -17,6 +17,18 @@ export interface UseFluxbaseQueryOptions<T> extends Omit<UseQueryOptions<T[], Er
17
17
  * Hook to execute a database query
18
18
  * @param buildQuery - Function that builds and returns the query
19
19
  * @param options - React Query options
20
+ *
21
+ * IMPORTANT: You must provide a stable `queryKey` in options for proper caching.
22
+ * Without a custom queryKey, each render may create a new cache entry.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * // Always provide a queryKey for stable caching
27
+ * useFluxbaseQuery(
28
+ * (client) => client.from('users').select('*'),
29
+ * { queryKey: ['users', 'all'] }
30
+ * )
31
+ * ```
20
32
  */
21
33
  export function useFluxbaseQuery<T = any>(
22
34
  buildQuery: (client: ReturnType<typeof useFluxbaseClient>) => QueryBuilder<T>,
@@ -24,8 +36,16 @@ export function useFluxbaseQuery<T = any>(
24
36
  ) {
25
37
  const client = useFluxbaseClient()
26
38
 
27
- // Build a stable query key
28
- const queryKey = options?.queryKey || ['fluxbase', 'query', buildQuery.toString()]
39
+ // Require queryKey for stable caching - function.toString() is not reliable
40
+ // as it can vary between renders for inline functions
41
+ if (!options?.queryKey) {
42
+ console.warn(
43
+ '[useFluxbaseQuery] No queryKey provided. This may cause cache misses. ' +
44
+ 'Please provide a stable queryKey in options.'
45
+ )
46
+ }
47
+
48
+ const queryKey = options?.queryKey || ['fluxbase', 'query', 'unstable']
29
49
 
30
50
  return useQuery({
31
51
  queryKey,
@@ -46,7 +66,23 @@ export function useFluxbaseQuery<T = any>(
46
66
  /**
47
67
  * Hook for table queries with a simpler API
48
68
  * @param table - Table name
49
- * @param buildQuery - Function to build the query
69
+ * @param buildQuery - Optional function to build the query (e.g., add filters)
70
+ * @param options - Query options including a stable queryKey
71
+ *
72
+ * NOTE: When using buildQuery with filters, provide a custom queryKey that includes
73
+ * the filter values to ensure proper caching.
74
+ *
75
+ * @example
76
+ * ```tsx
77
+ * // Simple query - queryKey is auto-generated from table name
78
+ * useTable('users')
79
+ *
80
+ * // With filters - provide queryKey including filter values
81
+ * useTable('users',
82
+ * (q) => q.eq('status', 'active'),
83
+ * { queryKey: ['users', 'active'] }
84
+ * )
85
+ * ```
50
86
  */
51
87
  export function useTable<T = any>(
52
88
  table: string,
@@ -55,6 +91,15 @@ export function useTable<T = any>(
55
91
  ) {
56
92
  const client = useFluxbaseClient()
57
93
 
94
+ // Generate a stable base queryKey from table name
95
+ // When buildQuery is provided without a custom queryKey, warn about potential cache issues
96
+ if (buildQuery && !options?.queryKey) {
97
+ console.warn(
98
+ `[useTable] Using buildQuery without a custom queryKey for table "${table}". ` +
99
+ 'This may cause cache misses. Provide a queryKey that includes your filter values.'
100
+ )
101
+ }
102
+
58
103
  return useFluxbaseQuery(
59
104
  (client) => {
60
105
  const query = client.from<T>(table)
@@ -62,7 +107,8 @@ export function useTable<T = any>(
62
107
  },
63
108
  {
64
109
  ...options,
65
- queryKey: options?.queryKey || ['fluxbase', 'table', table, buildQuery?.toString()],
110
+ // Use table name as base key, or custom key if provided
111
+ queryKey: options?.queryKey || ['fluxbase', 'table', table],
66
112
  }
67
113
  )
68
114
  }