@nimbleflux/fluxbase-sdk-react 2026.3.6-rc.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.
Files changed (42) hide show
  1. package/.nvmrc +1 -0
  2. package/README-ADMIN.md +1076 -0
  3. package/README.md +195 -0
  4. package/examples/AdminDashboard.tsx +513 -0
  5. package/examples/README.md +163 -0
  6. package/package.json +66 -0
  7. package/src/context.test.tsx +147 -0
  8. package/src/context.tsx +33 -0
  9. package/src/index.test.ts +255 -0
  10. package/src/index.ts +175 -0
  11. package/src/test-setup.ts +22 -0
  12. package/src/test-utils.tsx +215 -0
  13. package/src/use-admin-auth.test.ts +175 -0
  14. package/src/use-admin-auth.ts +187 -0
  15. package/src/use-admin-hooks.test.ts +457 -0
  16. package/src/use-admin-hooks.ts +309 -0
  17. package/src/use-auth-config.test.ts +145 -0
  18. package/src/use-auth-config.ts +101 -0
  19. package/src/use-auth.test.ts +313 -0
  20. package/src/use-auth.ts +164 -0
  21. package/src/use-captcha.test.ts +273 -0
  22. package/src/use-captcha.ts +250 -0
  23. package/src/use-client-keys.test.ts +286 -0
  24. package/src/use-client-keys.ts +185 -0
  25. package/src/use-graphql.test.ts +424 -0
  26. package/src/use-graphql.ts +392 -0
  27. package/src/use-query.test.ts +348 -0
  28. package/src/use-query.ts +211 -0
  29. package/src/use-realtime.test.ts +359 -0
  30. package/src/use-realtime.ts +180 -0
  31. package/src/use-saml.test.ts +269 -0
  32. package/src/use-saml.ts +221 -0
  33. package/src/use-storage.test.ts +549 -0
  34. package/src/use-storage.ts +508 -0
  35. package/src/use-table-export.ts +481 -0
  36. package/src/use-users.test.ts +264 -0
  37. package/src/use-users.ts +198 -0
  38. package/tsconfig.json +28 -0
  39. package/tsconfig.tsbuildinfo +1 -0
  40. package/tsup.config.ts +11 -0
  41. package/typedoc.json +33 -0
  42. package/vitest.config.ts +22 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Tests for realtime subscription hooks
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+ import { renderHook, waitFor, act } from '@testing-library/react';
7
+ import {
8
+ useRealtime,
9
+ useTableSubscription,
10
+ useTableInserts,
11
+ useTableUpdates,
12
+ useTableDeletes,
13
+ } from './use-realtime';
14
+ import { createMockClient, createWrapper, createTestQueryClient } from './test-utils';
15
+
16
+ describe('useRealtime', () => {
17
+ it('should create channel and subscribe', () => {
18
+ const onMock = vi.fn().mockReturnThis();
19
+ const subscribeMock = vi.fn().mockReturnThis();
20
+ const unsubscribeMock = vi.fn();
21
+ const channelMock = vi.fn().mockReturnValue({
22
+ on: onMock,
23
+ subscribe: subscribeMock,
24
+ unsubscribe: unsubscribeMock,
25
+ });
26
+
27
+ const client = createMockClient({
28
+ realtime: { channel: channelMock },
29
+ } as any);
30
+
31
+ renderHook(
32
+ () => useRealtime({ channel: 'table:products' }),
33
+ { wrapper: createWrapper(client) }
34
+ );
35
+
36
+ expect(channelMock).toHaveBeenCalledWith('table:products');
37
+ expect(onMock).toHaveBeenCalledWith('*', expect.any(Function));
38
+ expect(subscribeMock).toHaveBeenCalled();
39
+ });
40
+
41
+ it('should unsubscribe on unmount', () => {
42
+ const onMock = vi.fn().mockReturnThis();
43
+ const subscribeMock = vi.fn().mockReturnThis();
44
+ const unsubscribeMock = vi.fn();
45
+ const channelMock = vi.fn().mockReturnValue({
46
+ on: onMock,
47
+ subscribe: subscribeMock,
48
+ unsubscribe: unsubscribeMock,
49
+ });
50
+
51
+ const client = createMockClient({
52
+ realtime: { channel: channelMock },
53
+ } as any);
54
+
55
+ const { unmount } = renderHook(
56
+ () => useRealtime({ channel: 'table:products' }),
57
+ { wrapper: createWrapper(client) }
58
+ );
59
+
60
+ unmount();
61
+
62
+ expect(unsubscribeMock).toHaveBeenCalled();
63
+ });
64
+
65
+ it('should not subscribe when disabled', () => {
66
+ const channelMock = vi.fn();
67
+ const client = createMockClient({
68
+ realtime: { channel: channelMock },
69
+ } as any);
70
+
71
+ renderHook(
72
+ () => useRealtime({ channel: 'table:products', enabled: false }),
73
+ { wrapper: createWrapper(client) }
74
+ );
75
+
76
+ expect(channelMock).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('should subscribe to specific event type', () => {
80
+ const onMock = vi.fn().mockReturnThis();
81
+ const subscribeMock = vi.fn().mockReturnThis();
82
+ const channelMock = vi.fn().mockReturnValue({
83
+ on: onMock,
84
+ subscribe: subscribeMock,
85
+ unsubscribe: vi.fn(),
86
+ });
87
+
88
+ const client = createMockClient({
89
+ realtime: { channel: channelMock },
90
+ } as any);
91
+
92
+ renderHook(
93
+ () => useRealtime({ channel: 'table:products', event: 'INSERT' }),
94
+ { wrapper: createWrapper(client) }
95
+ );
96
+
97
+ expect(onMock).toHaveBeenCalledWith('INSERT', expect.any(Function));
98
+ });
99
+
100
+ it('should call callback on change', () => {
101
+ const callback = vi.fn();
102
+ let changeHandler: Function;
103
+ const onMock = vi.fn().mockImplementation((event, handler) => {
104
+ changeHandler = handler;
105
+ return { subscribe: vi.fn().mockReturnThis(), unsubscribe: vi.fn() };
106
+ });
107
+ const channelMock = vi.fn().mockReturnValue({
108
+ on: onMock,
109
+ subscribe: vi.fn().mockReturnThis(),
110
+ unsubscribe: vi.fn(),
111
+ });
112
+
113
+ const client = createMockClient({
114
+ realtime: { channel: channelMock },
115
+ } as any);
116
+
117
+ renderHook(
118
+ () => useRealtime({ channel: 'table:products', callback }),
119
+ { wrapper: createWrapper(client) }
120
+ );
121
+
122
+ const payload = { eventType: 'INSERT', new: { id: 1 }, old: null };
123
+ changeHandler!(payload);
124
+
125
+ expect(callback).toHaveBeenCalledWith(payload);
126
+ });
127
+
128
+ it('should auto-invalidate queries when enabled', () => {
129
+ let changeHandler: Function;
130
+ const onMock = vi.fn().mockImplementation((event, handler) => {
131
+ changeHandler = handler;
132
+ return { subscribe: vi.fn().mockReturnThis(), unsubscribe: vi.fn() };
133
+ });
134
+ const channelMock = vi.fn().mockReturnValue({
135
+ on: onMock,
136
+ subscribe: vi.fn().mockReturnThis(),
137
+ unsubscribe: vi.fn(),
138
+ });
139
+
140
+ const client = createMockClient({
141
+ realtime: { channel: channelMock },
142
+ } as any);
143
+
144
+ const queryClient = createTestQueryClient();
145
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
146
+
147
+ renderHook(
148
+ () => useRealtime({ channel: 'table:public.products', autoInvalidate: true }),
149
+ { wrapper: createWrapper(client, queryClient) }
150
+ );
151
+
152
+ const payload = { eventType: 'INSERT', new: { id: 1 }, old: null };
153
+ changeHandler!(payload);
154
+
155
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'table', 'public.products'] });
156
+ });
157
+
158
+ it('should use custom invalidate key', () => {
159
+ let changeHandler: Function;
160
+ const onMock = vi.fn().mockImplementation((event, handler) => {
161
+ changeHandler = handler;
162
+ return { subscribe: vi.fn().mockReturnThis(), unsubscribe: vi.fn() };
163
+ });
164
+ const channelMock = vi.fn().mockReturnValue({
165
+ on: onMock,
166
+ subscribe: vi.fn().mockReturnThis(),
167
+ unsubscribe: vi.fn(),
168
+ });
169
+
170
+ const client = createMockClient({
171
+ realtime: { channel: channelMock },
172
+ } as any);
173
+
174
+ const queryClient = createTestQueryClient();
175
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
176
+
177
+ renderHook(
178
+ () => useRealtime({
179
+ channel: 'table:products',
180
+ autoInvalidate: true,
181
+ invalidateKey: ['custom', 'key'],
182
+ }),
183
+ { wrapper: createWrapper(client, queryClient) }
184
+ );
185
+
186
+ const payload = { eventType: 'INSERT', new: { id: 1 }, old: null };
187
+ changeHandler!(payload);
188
+
189
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['custom', 'key'] });
190
+ });
191
+
192
+ it('should not auto-invalidate when disabled', () => {
193
+ let changeHandler: Function;
194
+ const onMock = vi.fn().mockImplementation((event, handler) => {
195
+ changeHandler = handler;
196
+ return { subscribe: vi.fn().mockReturnThis(), unsubscribe: vi.fn() };
197
+ });
198
+ const channelMock = vi.fn().mockReturnValue({
199
+ on: onMock,
200
+ subscribe: vi.fn().mockReturnThis(),
201
+ unsubscribe: vi.fn(),
202
+ });
203
+
204
+ const client = createMockClient({
205
+ realtime: { channel: channelMock },
206
+ } as any);
207
+
208
+ const queryClient = createTestQueryClient();
209
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
210
+
211
+ renderHook(
212
+ () => useRealtime({ channel: 'table:products', autoInvalidate: false }),
213
+ { wrapper: createWrapper(client, queryClient) }
214
+ );
215
+
216
+ const payload = { eventType: 'INSERT', new: { id: 1 }, old: null };
217
+ changeHandler!(payload);
218
+
219
+ expect(invalidateSpy).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('should return channel property', () => {
223
+ const mockChannel = {
224
+ on: vi.fn().mockReturnThis(),
225
+ subscribe: vi.fn().mockReturnThis(),
226
+ unsubscribe: vi.fn(),
227
+ };
228
+ const channelMock = vi.fn().mockReturnValue(mockChannel);
229
+
230
+ const client = createMockClient({
231
+ realtime: { channel: channelMock },
232
+ } as any);
233
+
234
+ const { result } = renderHook(
235
+ () => useRealtime({ channel: 'table:products' }),
236
+ { wrapper: createWrapper(client) }
237
+ );
238
+
239
+ // The hook returns a channel property (may be null initially, but is set by useEffect)
240
+ expect(result.current).toHaveProperty('channel');
241
+ // Verify the channel was created
242
+ expect(channelMock).toHaveBeenCalledWith('table:products');
243
+ });
244
+ });
245
+
246
+ describe('useTableSubscription', () => {
247
+ it('should subscribe to table with correct channel name', () => {
248
+ const onMock = vi.fn().mockReturnThis();
249
+ const channelMock = vi.fn().mockReturnValue({
250
+ on: onMock,
251
+ subscribe: vi.fn().mockReturnThis(),
252
+ unsubscribe: vi.fn(),
253
+ });
254
+
255
+ const client = createMockClient({
256
+ realtime: { channel: channelMock },
257
+ } as any);
258
+
259
+ renderHook(
260
+ () => useTableSubscription('products'),
261
+ { wrapper: createWrapper(client) }
262
+ );
263
+
264
+ expect(channelMock).toHaveBeenCalledWith('table:products');
265
+ });
266
+
267
+ it('should pass options through', () => {
268
+ const callback = vi.fn();
269
+ const onMock = vi.fn().mockReturnThis();
270
+ const channelMock = vi.fn().mockReturnValue({
271
+ on: onMock,
272
+ subscribe: vi.fn().mockReturnThis(),
273
+ unsubscribe: vi.fn(),
274
+ });
275
+
276
+ const client = createMockClient({
277
+ realtime: { channel: channelMock },
278
+ } as any);
279
+
280
+ renderHook(
281
+ () => useTableSubscription('products', { callback, autoInvalidate: false }),
282
+ { wrapper: createWrapper(client) }
283
+ );
284
+
285
+ expect(channelMock).toHaveBeenCalledWith('table:products');
286
+ });
287
+ });
288
+
289
+ describe('useTableInserts', () => {
290
+ it('should subscribe to INSERT events', () => {
291
+ const onMock = vi.fn().mockReturnThis();
292
+ const channelMock = vi.fn().mockReturnValue({
293
+ on: onMock,
294
+ subscribe: vi.fn().mockReturnThis(),
295
+ unsubscribe: vi.fn(),
296
+ });
297
+
298
+ const client = createMockClient({
299
+ realtime: { channel: channelMock },
300
+ } as any);
301
+
302
+ const callback = vi.fn();
303
+ renderHook(
304
+ () => useTableInserts('products', callback),
305
+ { wrapper: createWrapper(client) }
306
+ );
307
+
308
+ expect(channelMock).toHaveBeenCalledWith('table:products');
309
+ expect(onMock).toHaveBeenCalledWith('INSERT', expect.any(Function));
310
+ });
311
+ });
312
+
313
+ describe('useTableUpdates', () => {
314
+ it('should subscribe to UPDATE events', () => {
315
+ const onMock = vi.fn().mockReturnThis();
316
+ const channelMock = vi.fn().mockReturnValue({
317
+ on: onMock,
318
+ subscribe: vi.fn().mockReturnThis(),
319
+ unsubscribe: vi.fn(),
320
+ });
321
+
322
+ const client = createMockClient({
323
+ realtime: { channel: channelMock },
324
+ } as any);
325
+
326
+ const callback = vi.fn();
327
+ renderHook(
328
+ () => useTableUpdates('products', callback),
329
+ { wrapper: createWrapper(client) }
330
+ );
331
+
332
+ expect(channelMock).toHaveBeenCalledWith('table:products');
333
+ expect(onMock).toHaveBeenCalledWith('UPDATE', expect.any(Function));
334
+ });
335
+ });
336
+
337
+ describe('useTableDeletes', () => {
338
+ it('should subscribe to DELETE events', () => {
339
+ const onMock = vi.fn().mockReturnThis();
340
+ const channelMock = vi.fn().mockReturnValue({
341
+ on: onMock,
342
+ subscribe: vi.fn().mockReturnThis(),
343
+ unsubscribe: vi.fn(),
344
+ });
345
+
346
+ const client = createMockClient({
347
+ realtime: { channel: channelMock },
348
+ } as any);
349
+
350
+ const callback = vi.fn();
351
+ renderHook(
352
+ () => useTableDeletes('products', callback),
353
+ { wrapper: createWrapper(client) }
354
+ );
355
+
356
+ expect(channelMock).toHaveBeenCalledWith('table:products');
357
+ expect(onMock).toHaveBeenCalledWith('DELETE', expect.any(Function));
358
+ });
359
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Realtime subscription hooks for Fluxbase SDK
3
+ */
4
+
5
+ import { useEffect, useRef } from "react";
6
+ import { useQueryClient } from "@tanstack/react-query";
7
+ import { useFluxbaseClient } from "./context";
8
+ import type {
9
+ RealtimeCallback,
10
+ RealtimePostgresChangesPayload,
11
+ } from "@fluxbase/sdk";
12
+
13
+ export interface UseRealtimeOptions {
14
+ /**
15
+ * The channel name (e.g., 'table:public.products')
16
+ */
17
+ channel: string;
18
+
19
+ /**
20
+ * Event type to listen for ('INSERT', 'UPDATE', 'DELETE', or '*' for all)
21
+ */
22
+ event?: "INSERT" | "UPDATE" | "DELETE" | "*";
23
+
24
+ /**
25
+ * Callback function when an event is received
26
+ */
27
+ callback?: RealtimeCallback;
28
+
29
+ /**
30
+ * Whether to automatically invalidate queries for the table
31
+ * Default: true
32
+ */
33
+ autoInvalidate?: boolean;
34
+
35
+ /**
36
+ * Custom query key to invalidate (if autoInvalidate is true)
37
+ * Default: ['fluxbase', 'table', tableName]
38
+ */
39
+ invalidateKey?: unknown[];
40
+
41
+ /**
42
+ * Whether the subscription is enabled
43
+ * Default: true
44
+ */
45
+ enabled?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Hook to subscribe to realtime changes for a channel
50
+ *
51
+ * NOTE: The callback and invalidateKey are stored in refs to prevent
52
+ * subscription recreation on every render when inline functions/arrays are used.
53
+ */
54
+ export function useRealtime(options: UseRealtimeOptions) {
55
+ const client = useFluxbaseClient();
56
+ const queryClient = useQueryClient();
57
+ const channelRef = useRef<ReturnType<typeof client.realtime.channel> | null>(
58
+ null,
59
+ );
60
+
61
+ const {
62
+ channel: channelName,
63
+ event = "*",
64
+ callback,
65
+ autoInvalidate = true,
66
+ invalidateKey,
67
+ enabled = true,
68
+ } = options;
69
+
70
+ // Store callback and invalidateKey in refs to avoid subscription recreation
71
+ // when inline functions/arrays are passed
72
+ const callbackRef = useRef(callback);
73
+ const invalidateKeyRef = useRef(invalidateKey);
74
+ const autoInvalidateRef = useRef(autoInvalidate);
75
+
76
+ // Keep refs up to date
77
+ callbackRef.current = callback;
78
+ invalidateKeyRef.current = invalidateKey;
79
+ autoInvalidateRef.current = autoInvalidate;
80
+
81
+ useEffect(() => {
82
+ if (!enabled) {
83
+ return;
84
+ }
85
+
86
+ // Create channel and subscribe
87
+ const channel = client.realtime.channel(channelName);
88
+ channelRef.current = channel;
89
+
90
+ const handleChange = (payload: RealtimePostgresChangesPayload) => {
91
+ // Call user callback (using ref for latest value)
92
+ if (callbackRef.current) {
93
+ callbackRef.current(payload);
94
+ }
95
+
96
+ // Auto-invalidate queries if enabled
97
+ if (autoInvalidateRef.current) {
98
+ // Extract table name from channel (e.g., 'table:public.products' -> 'public.products')
99
+ const tableName = channelName.replace(/^table:/, "");
100
+
101
+ const key = invalidateKeyRef.current || ["fluxbase", "table", tableName];
102
+ queryClient.invalidateQueries({ queryKey: key });
103
+ }
104
+ };
105
+
106
+ channel.on(event, handleChange).subscribe();
107
+
108
+ return () => {
109
+ channel.unsubscribe();
110
+ channelRef.current = null;
111
+ };
112
+ }, [client, channelName, event, queryClient, enabled]);
113
+
114
+ return {
115
+ channel: channelRef.current,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Hook to subscribe to a table's changes
121
+ * @param table - Table name (with optional schema, e.g., 'public.products')
122
+ * @param options - Subscription options
123
+ */
124
+ export function useTableSubscription(
125
+ table: string,
126
+ options?: Omit<UseRealtimeOptions, "channel">,
127
+ ) {
128
+ return useRealtime({
129
+ ...options,
130
+ channel: `table:${table}`,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Hook to subscribe to INSERT events on a table
136
+ */
137
+ export function useTableInserts(
138
+ table: string,
139
+ callback: (payload: RealtimePostgresChangesPayload) => void,
140
+ options?: Omit<UseRealtimeOptions, "channel" | "event" | "callback">,
141
+ ) {
142
+ return useRealtime({
143
+ ...options,
144
+ channel: `table:${table}`,
145
+ event: "INSERT",
146
+ callback,
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Hook to subscribe to UPDATE events on a table
152
+ */
153
+ export function useTableUpdates(
154
+ table: string,
155
+ callback: (payload: RealtimePostgresChangesPayload) => void,
156
+ options?: Omit<UseRealtimeOptions, "channel" | "event" | "callback">,
157
+ ) {
158
+ return useRealtime({
159
+ ...options,
160
+ channel: `table:${table}`,
161
+ event: "UPDATE",
162
+ callback,
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Hook to subscribe to DELETE events on a table
168
+ */
169
+ export function useTableDeletes(
170
+ table: string,
171
+ callback: (payload: RealtimePostgresChangesPayload) => void,
172
+ options?: Omit<UseRealtimeOptions, "channel" | "event" | "callback">,
173
+ ) {
174
+ return useRealtime({
175
+ ...options,
176
+ channel: `table:${table}`,
177
+ event: "DELETE",
178
+ callback,
179
+ });
180
+ }