@nimbleflux/fluxbase-sdk-react 2026.5.5-rc.1 → 2026.6.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,237 @@
1
+ /**
2
+ * Tests for Service Keys hooks
3
+ */
4
+
5
+ import { describe, it, expect, vi } from "vitest";
6
+ import { renderHook, waitFor, act } from "@testing-library/react";
7
+ import {
8
+ useServiceKeys,
9
+ useCreateServiceKey,
10
+ useRotateServiceKey,
11
+ useRevokeServiceKey,
12
+ } from "./use-service-keys";
13
+ import { createMockClient, createWrapper } from "./test-utils";
14
+
15
+ describe("useServiceKeys", () => {
16
+ it("should list service keys", async () => {
17
+ const mockKeys = [
18
+ {
19
+ id: "1",
20
+ name: "Production Key",
21
+ key_type: "service" as const,
22
+ scopes: ["*"],
23
+ enabled: true,
24
+ key_prefix: "fb_prod_abcdef",
25
+ created_at: "",
26
+ },
27
+ ];
28
+ const client = createMockClient();
29
+ (
30
+ client.admin.serviceKeys.list as ReturnType<typeof vi.fn>
31
+ ).mockResolvedValue({ data: mockKeys, error: null });
32
+
33
+ const { result } = renderHook(() => useServiceKeys(), {
34
+ wrapper: createWrapper(client),
35
+ });
36
+
37
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
38
+ expect(result.current.data).toEqual(mockKeys);
39
+ });
40
+
41
+ it("should handle errors", async () => {
42
+ const client = createMockClient();
43
+ (
44
+ client.admin.serviceKeys.list as ReturnType<typeof vi.fn>
45
+ ).mockResolvedValue({ data: null, error: new Error("Unauthorized") });
46
+
47
+ const { result } = renderHook(() => useServiceKeys(), {
48
+ wrapper: createWrapper(client),
49
+ });
50
+
51
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
52
+ expect(result.current.error).toBeInstanceOf(Error);
53
+ });
54
+ });
55
+
56
+ describe("useCreateServiceKey", () => {
57
+ it("should create a service key and invalidate queries", async () => {
58
+ const mockKey = {
59
+ id: "2",
60
+ name: "New Key",
61
+ key_type: "service" as const,
62
+ scopes: ["*"],
63
+ enabled: true,
64
+ key_prefix: "fb_new_123456",
65
+ key: "fb_new_123456789",
66
+ created_at: "",
67
+ };
68
+ const client = createMockClient();
69
+ (
70
+ client.admin.serviceKeys.create as ReturnType<typeof vi.fn>
71
+ ).mockResolvedValue({ data: mockKey, error: null });
72
+
73
+ const { result } = renderHook(() => useCreateServiceKey(), {
74
+ wrapper: createWrapper(client),
75
+ });
76
+
77
+ await act(async () => {
78
+ await result.current.mutateAsync({
79
+ name: "New Key",
80
+ key_type: "service",
81
+ scopes: ["*"],
82
+ });
83
+ });
84
+
85
+ expect(client.admin.serviceKeys.create).toHaveBeenCalledWith({
86
+ name: "New Key",
87
+ key_type: "service",
88
+ scopes: ["*"],
89
+ });
90
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
91
+ expect(result.current.data).toEqual(mockKey);
92
+ });
93
+
94
+ it("should handle errors", async () => {
95
+ const client = createMockClient();
96
+ (
97
+ client.admin.serviceKeys.create as ReturnType<typeof vi.fn>
98
+ ).mockResolvedValue({ data: null, error: new Error("Limit reached") });
99
+
100
+ const { result } = renderHook(() => useCreateServiceKey(), {
101
+ wrapper: createWrapper(client),
102
+ });
103
+
104
+ await act(async () => {
105
+ try {
106
+ await result.current.mutateAsync({
107
+ name: "Excess",
108
+ key_type: "service",
109
+ });
110
+ } catch {
111
+ // expected
112
+ }
113
+ });
114
+
115
+ await waitFor(() => expect(result.current.isError).toBe(true));
116
+ });
117
+ });
118
+
119
+ describe("useRotateServiceKey", () => {
120
+ it("should rotate a service key and invalidate queries", async () => {
121
+ const mockKey = {
122
+ id: "3",
123
+ name: "Rotated Key",
124
+ key_type: "service" as const,
125
+ scopes: ["*"],
126
+ enabled: true,
127
+ key_prefix: "fb_rot_newpass",
128
+ key: "fb_rot_newpassword",
129
+ created_at: "",
130
+ deprecated_at: "",
131
+ grace_period_ends_at: "",
132
+ };
133
+ const client = createMockClient();
134
+ (
135
+ client.admin.serviceKeys.rotate as ReturnType<typeof vi.fn>
136
+ ).mockResolvedValue({ data: mockKey, error: null });
137
+
138
+ const { result } = renderHook(() => useRotateServiceKey(), {
139
+ wrapper: createWrapper(client),
140
+ });
141
+
142
+ await act(async () => {
143
+ await result.current.mutateAsync("old-key-id");
144
+ });
145
+
146
+ expect(client.admin.serviceKeys.rotate).toHaveBeenCalledWith("old-key-id");
147
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
148
+ expect(result.current.data).toEqual(mockKey);
149
+ });
150
+
151
+ it("should handle errors", async () => {
152
+ const client = createMockClient();
153
+ (
154
+ client.admin.serviceKeys.rotate as ReturnType<typeof vi.fn>
155
+ ).mockResolvedValue({ data: null, error: new Error("Key not found") });
156
+
157
+ const { result } = renderHook(() => useRotateServiceKey(), {
158
+ wrapper: createWrapper(client),
159
+ });
160
+
161
+ await act(async () => {
162
+ try {
163
+ await result.current.mutateAsync("missing-id");
164
+ } catch {
165
+ // expected
166
+ }
167
+ });
168
+
169
+ await waitFor(() => expect(result.current.isError).toBe(true));
170
+ });
171
+ });
172
+
173
+ describe("useRevokeServiceKey", () => {
174
+ it("should revoke a service key and invalidate queries", async () => {
175
+ const client = createMockClient();
176
+ (
177
+ client.admin.serviceKeys.revoke as ReturnType<typeof vi.fn>
178
+ ).mockResolvedValue({ error: null });
179
+
180
+ const { result } = renderHook(() => useRevokeServiceKey(), {
181
+ wrapper: createWrapper(client),
182
+ });
183
+
184
+ await act(async () => {
185
+ await result.current.mutateAsync({ id: "compromised-id" });
186
+ });
187
+
188
+ expect(client.admin.serviceKeys.revoke).toHaveBeenCalledWith(
189
+ "compromised-id",
190
+ undefined,
191
+ );
192
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
193
+ });
194
+
195
+ it("should pass revocation reason", async () => {
196
+ const client = createMockClient();
197
+ (
198
+ client.admin.serviceKeys.revoke as ReturnType<typeof vi.fn>
199
+ ).mockResolvedValue({ error: null });
200
+
201
+ const { result } = renderHook(() => useRevokeServiceKey(), {
202
+ wrapper: createWrapper(client),
203
+ });
204
+
205
+ await act(async () => {
206
+ await result.current.mutateAsync({
207
+ id: "key-id",
208
+ request: { reason: "Key was compromised" },
209
+ });
210
+ });
211
+
212
+ expect(client.admin.serviceKeys.revoke).toHaveBeenCalledWith("key-id", {
213
+ reason: "Key was compromised",
214
+ });
215
+ });
216
+
217
+ it("should handle errors", async () => {
218
+ const client = createMockClient();
219
+ (
220
+ client.admin.serviceKeys.revoke as ReturnType<typeof vi.fn>
221
+ ).mockResolvedValue({ error: new Error("Already revoked") });
222
+
223
+ const { result } = renderHook(() => useRevokeServiceKey(), {
224
+ wrapper: createWrapper(client),
225
+ });
226
+
227
+ await act(async () => {
228
+ try {
229
+ await result.current.mutateAsync({ id: "bad-id" });
230
+ } catch {
231
+ // expected
232
+ }
233
+ });
234
+
235
+ await waitFor(() => expect(result.current.isError).toBe(true));
236
+ });
237
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Service keys hooks for Fluxbase React SDK
3
+ * Provides hooks for managing tenant service keys (anon and service)
4
+ */
5
+
6
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
7
+ import { useFluxbaseClient } from "./context";
8
+ import type {
9
+ CreateServiceKeyRequest,
10
+ RevokeServiceKeyRequest,
11
+ } from "@nimbleflux/fluxbase-sdk";
12
+
13
+ /**
14
+ * Hook to list all service keys
15
+ */
16
+ export function useServiceKeys() {
17
+ const client = useFluxbaseClient();
18
+
19
+ return useQuery({
20
+ queryKey: ["fluxbase", "service-keys"],
21
+ queryFn: async () => {
22
+ const { data, error } = await client.admin.serviceKeys.list();
23
+ if (error) throw error;
24
+ return data;
25
+ },
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Hook to create a new service key
31
+ *
32
+ * The full key value is only returned once — store it securely!
33
+ */
34
+ export function useCreateServiceKey() {
35
+ const client = useFluxbaseClient();
36
+ const queryClient = useQueryClient();
37
+
38
+ return useMutation({
39
+ mutationFn: async (request: CreateServiceKeyRequest) => {
40
+ const { data, error } = await client.admin.serviceKeys.create(request);
41
+ if (error) throw error;
42
+ return data;
43
+ },
44
+ onSuccess: () => {
45
+ queryClient.invalidateQueries({ queryKey: ["fluxbase", "service-keys"] });
46
+ },
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Hook to rotate a service key (creates a replacement and deprecates the old one)
52
+ */
53
+ export function useRotateServiceKey() {
54
+ const client = useFluxbaseClient();
55
+ const queryClient = useQueryClient();
56
+
57
+ return useMutation({
58
+ mutationFn: async (id: string) => {
59
+ const { data, error } = await client.admin.serviceKeys.rotate(id);
60
+ if (error) throw error;
61
+ return data;
62
+ },
63
+ onSuccess: () => {
64
+ queryClient.invalidateQueries({ queryKey: ["fluxbase", "service-keys"] });
65
+ },
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Hook to revoke a service key permanently (emergency)
71
+ */
72
+ export function useRevokeServiceKey() {
73
+ const client = useFluxbaseClient();
74
+ const queryClient = useQueryClient();
75
+
76
+ return useMutation({
77
+ mutationFn: async (params: {
78
+ id: string;
79
+ request?: RevokeServiceKeyRequest;
80
+ }) => {
81
+ const { error } = await client.admin.serviceKeys.revoke(
82
+ params.id,
83
+ params.request,
84
+ );
85
+ if (error) throw error;
86
+ },
87
+ onSuccess: () => {
88
+ queryClient.invalidateQueries({ queryKey: ["fluxbase", "service-keys"] });
89
+ },
90
+ });
91
+ }
package/src/use-tenant.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * @module use-tenant
5
5
  */
6
6
 
7
- import { useState, useEffect, useCallback } from "react";
7
+ import { useState, useCallback } from "react";
8
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
8
9
  import { useFluxbaseClient } from "./context";
9
10
  import type {
10
11
  Tenant,
@@ -98,58 +99,55 @@ export interface UseTenantsReturn {
98
99
  export function useTenants(options: UseTenantsOptions = {}): UseTenantsReturn {
99
100
  const { autoFetch = true } = options;
100
101
  const client = useFluxbaseClient();
102
+ const queryClient = useQueryClient();
101
103
 
102
- const [tenants, setTenants] = useState<TenantWithRole[]>([]);
103
- const [isLoading, setIsLoading] = useState(autoFetch);
104
- const [error, setError] = useState<Error | null>(null);
105
104
  const [currentTenantId, setCurrentTenantId] = useState<string | undefined>(
106
105
  client.getTenantId(),
107
106
  );
108
107
 
109
- const fetchTenants = useCallback(async () => {
110
- try {
111
- setIsLoading(true);
112
- setError(null);
113
- const { data, error: fetchError } = await client.tenant.listMine();
114
- if (fetchError) {
115
- setError(fetchError);
116
- } else {
117
- setTenants(data || []);
118
- }
119
- } catch (err) {
120
- setError(err as Error);
121
- } finally {
122
- setIsLoading(false);
123
- }
124
- }, [client]);
108
+ const query = useQuery({
109
+ queryKey: ["fluxbase", "tenants", "mine"],
110
+ queryFn: async () => {
111
+ const { data, error } = await client.tenant.listMine();
112
+ if (error) throw error;
113
+ return data || ([] as TenantWithRole[]);
114
+ },
115
+ enabled: autoFetch,
116
+ });
125
117
 
126
118
  const createTenant = useCallback(
127
119
  async (opts: CreateTenantOptions): Promise<Tenant> => {
128
120
  const { data, error: createError } = await client.tenant.create(opts);
129
121
  if (createError) throw createError;
130
- await fetchTenants();
122
+ await queryClient.invalidateQueries({
123
+ queryKey: ["fluxbase", "tenants"],
124
+ });
131
125
  return data!;
132
126
  },
133
- [client, fetchTenants],
127
+ [client, queryClient],
134
128
  );
135
129
 
136
130
  const updateTenant = useCallback(
137
131
  async (id: string, opts: UpdateTenantOptions): Promise<Tenant> => {
138
132
  const { data, error: updateError } = await client.tenant.update(id, opts);
139
133
  if (updateError) throw updateError;
140
- await fetchTenants();
134
+ await queryClient.invalidateQueries({
135
+ queryKey: ["fluxbase", "tenants"],
136
+ });
141
137
  return data!;
142
138
  },
143
- [client, fetchTenants],
139
+ [client, queryClient],
144
140
  );
145
141
 
146
142
  const deleteTenant = useCallback(
147
143
  async (id: string): Promise<void> => {
148
144
  const { error: deleteError } = await client.tenant.delete(id);
149
145
  if (deleteError) throw deleteError;
150
- await fetchTenants();
146
+ await queryClient.invalidateQueries({
147
+ queryKey: ["fluxbase", "tenants"],
148
+ });
151
149
  },
152
- [client, fetchTenants],
150
+ [client, queryClient],
153
151
  );
154
152
 
155
153
  const setCurrentTenant = useCallback(
@@ -160,17 +158,13 @@ export function useTenants(options: UseTenantsOptions = {}): UseTenantsReturn {
160
158
  [client],
161
159
  );
162
160
 
163
- useEffect(() => {
164
- if (autoFetch) {
165
- fetchTenants();
166
- }
167
- }, [autoFetch, fetchTenants]);
168
-
169
161
  return {
170
- tenants,
171
- isLoading,
172
- error,
173
- refetch: fetchTenants,
162
+ tenants: query.data ?? [],
163
+ isLoading: autoFetch ? query.isLoading : false,
164
+ error: query.error,
165
+ refetch: async () => {
166
+ await query.refetch();
167
+ },
174
168
  createTenant,
175
169
  updateTenant,
176
170
  deleteTenant,
@@ -249,31 +243,17 @@ export interface UseTenantReturn {
249
243
  export function useTenant(options: UseTenantOptions): UseTenantReturn {
250
244
  const { tenantId, autoFetch = true } = options;
251
245
  const client = useFluxbaseClient();
252
-
253
- const [tenant, setTenant] = useState<Tenant | null>(null);
254
- const [isLoading, setIsLoading] = useState(autoFetch);
255
- const [error, setError] = useState<Error | null>(null);
256
-
257
- const fetchTenant = useCallback(async () => {
258
- if (!tenantId) return;
259
-
260
- try {
261
- setIsLoading(true);
262
- setError(null);
263
- const { data, error: fetchError } = await client.tenant.get(tenantId);
264
- if (fetchError) {
265
- setError(fetchError);
266
- setTenant(null);
267
- } else {
268
- setTenant(data);
269
- }
270
- } catch (err) {
271
- setError(err as Error);
272
- setTenant(null);
273
- } finally {
274
- setIsLoading(false);
275
- }
276
- }, [client, tenantId]);
246
+ const queryClient = useQueryClient();
247
+
248
+ const query = useQuery({
249
+ queryKey: ["fluxbase", "tenant", tenantId],
250
+ queryFn: async () => {
251
+ const { data, error } = await client.tenant.get(tenantId);
252
+ if (error) throw error;
253
+ return data;
254
+ },
255
+ enabled: autoFetch && !!tenantId,
256
+ });
277
257
 
278
258
  const update = useCallback(
279
259
  async (opts: UpdateTenantOptions): Promise<Tenant> => {
@@ -282,29 +262,29 @@ export function useTenant(options: UseTenantOptions): UseTenantReturn {
282
262
  opts,
283
263
  );
284
264
  if (updateError) throw updateError;
285
- setTenant(data);
265
+ await queryClient.invalidateQueries({
266
+ queryKey: ["fluxbase", "tenant", tenantId],
267
+ });
286
268
  return data!;
287
269
  },
288
- [client, tenantId],
270
+ [client, tenantId, queryClient],
289
271
  );
290
272
 
291
273
  const remove = useCallback(async (): Promise<void> => {
292
274
  const { error: deleteError } = await client.tenant.delete(tenantId);
293
275
  if (deleteError) throw deleteError;
294
- setTenant(null);
295
- }, [client, tenantId]);
296
-
297
- useEffect(() => {
298
- if (autoFetch && tenantId) {
299
- fetchTenant();
300
- }
301
- }, [autoFetch, fetchTenant, tenantId]);
276
+ await queryClient.invalidateQueries({
277
+ queryKey: ["fluxbase", "tenant", tenantId],
278
+ });
279
+ }, [client, tenantId, queryClient]);
302
280
 
303
281
  return {
304
- tenant,
305
- isLoading,
306
- error,
307
- refetch: fetchTenant,
282
+ tenant: query.data ?? null,
283
+ isLoading: autoFetch ? query.isLoading : false,
284
+ error: query.error,
285
+ refetch: async () => {
286
+ await query.refetch();
287
+ },
308
288
  update,
309
289
  remove,
310
290
  };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Tests for Vector hooks
3
+ */
4
+
5
+ import { describe, it, expect, vi } from "vitest";
6
+ import { renderHook, waitFor, act } from "@testing-library/react";
7
+ import { useVectorEmbed, useVectorSearch } from "./use-vector";
8
+ import { createMockClient, createWrapper } from "./test-utils";
9
+
10
+ describe("useVectorEmbed", () => {
11
+ it("should embed text", async () => {
12
+ const mockResponse = {
13
+ embeddings: [[0.1, 0.2, 0.3]],
14
+ model: "text-embedding-3-small",
15
+ dimensions: 3,
16
+ };
17
+ const client = createMockClient();
18
+ (client.vector.embed as ReturnType<typeof vi.fn>).mockResolvedValue({
19
+ data: mockResponse,
20
+ error: null,
21
+ });
22
+
23
+ const { result } = renderHook(() => useVectorEmbed(), {
24
+ wrapper: createWrapper(client),
25
+ });
26
+
27
+ await act(async () => {
28
+ await result.current.mutateAsync({ text: "Hello world" });
29
+ });
30
+
31
+ expect(client.vector.embed).toHaveBeenCalledWith({ text: "Hello world" });
32
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
33
+ expect(result.current.data).toEqual(mockResponse);
34
+ });
35
+
36
+ it("should embed multiple texts", async () => {
37
+ const mockResponse = {
38
+ embeddings: [[0.1], [0.2]],
39
+ model: "text-embedding-3-small",
40
+ dimensions: 1,
41
+ };
42
+ const client = createMockClient();
43
+ (client.vector.embed as ReturnType<typeof vi.fn>).mockResolvedValue({
44
+ data: mockResponse,
45
+ error: null,
46
+ });
47
+
48
+ const { result } = renderHook(() => useVectorEmbed(), {
49
+ wrapper: createWrapper(client),
50
+ });
51
+
52
+ await act(async () => {
53
+ await result.current.mutateAsync({
54
+ texts: ["Hello", "World"],
55
+ model: "text-embedding-3-large",
56
+ });
57
+ });
58
+
59
+ expect(client.vector.embed).toHaveBeenCalledWith({
60
+ texts: ["Hello", "World"],
61
+ model: "text-embedding-3-large",
62
+ });
63
+ });
64
+
65
+ it("should handle errors", async () => {
66
+ const client = createMockClient();
67
+ (client.vector.embed as ReturnType<typeof vi.fn>).mockResolvedValue({
68
+ data: null,
69
+ error: new Error("Embedding failed"),
70
+ });
71
+
72
+ const { result } = renderHook(() => useVectorEmbed(), {
73
+ wrapper: createWrapper(client),
74
+ });
75
+
76
+ await act(async () => {
77
+ try {
78
+ await result.current.mutateAsync({ text: "test" });
79
+ } catch {
80
+ // expected
81
+ }
82
+ });
83
+
84
+ await waitFor(() => expect(result.current.isError).toBe(true));
85
+ });
86
+ });
87
+
88
+ describe("useVectorSearch", () => {
89
+ it("should search with text query", async () => {
90
+ const mockResult = {
91
+ data: [{ id: "1", content: "Match 1" }],
92
+ distances: [0.1],
93
+ model: "text-embedding-3-small",
94
+ };
95
+ const client = createMockClient();
96
+ (client.vector.search as ReturnType<typeof vi.fn>).mockResolvedValue({
97
+ data: mockResult,
98
+ error: null,
99
+ });
100
+
101
+ const { result } = renderHook(() => useVectorSearch(), {
102
+ wrapper: createWrapper(client),
103
+ });
104
+
105
+ const searchOpts = {
106
+ table: "documents",
107
+ column: "embedding",
108
+ query: "How to use TypeScript?",
109
+ match_count: 10,
110
+ };
111
+
112
+ await act(async () => {
113
+ await result.current.mutateAsync(searchOpts);
114
+ });
115
+
116
+ expect(client.vector.search).toHaveBeenCalledWith(searchOpts);
117
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
118
+ expect(result.current.data).toEqual(mockResult);
119
+ });
120
+
121
+ it("should search with pre-computed vector", async () => {
122
+ const mockResult = {
123
+ data: [{ id: "2", content: "Vector match" }],
124
+ distances: [0.05],
125
+ };
126
+ const client = createMockClient();
127
+ (client.vector.search as ReturnType<typeof vi.fn>).mockResolvedValue({
128
+ data: mockResult,
129
+ error: null,
130
+ });
131
+
132
+ const { result } = renderHook(() => useVectorSearch(), {
133
+ wrapper: createWrapper(client),
134
+ });
135
+
136
+ const searchOpts = {
137
+ table: "documents",
138
+ column: "embedding",
139
+ vector: [0.1, 0.2, 0.3],
140
+ metric: "cosine" as const,
141
+ match_count: 5,
142
+ };
143
+
144
+ await act(async () => {
145
+ await result.current.mutateAsync(searchOpts);
146
+ });
147
+
148
+ expect(client.vector.search).toHaveBeenCalledWith(searchOpts);
149
+ });
150
+
151
+ it("should handle errors", async () => {
152
+ const client = createMockClient();
153
+ (client.vector.search as ReturnType<typeof vi.fn>).mockResolvedValue({
154
+ data: null,
155
+ error: new Error("Search failed"),
156
+ });
157
+
158
+ const { result } = renderHook(() => useVectorSearch(), {
159
+ wrapper: createWrapper(client),
160
+ });
161
+
162
+ await act(async () => {
163
+ try {
164
+ await result.current.mutateAsync({
165
+ table: "documents",
166
+ column: "embedding",
167
+ query: "test",
168
+ });
169
+ } catch {
170
+ // expected
171
+ }
172
+ });
173
+
174
+ await waitFor(() => expect(result.current.isError).toBe(true));
175
+ });
176
+ });