@gonvex/react 0.1.32 → 0.3.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.
- package/README.md +77 -45
- package/dist/index.d.ts +59 -35
- package/dist/index.js +348 -222
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { GonvexClientError, control } from "@gonvex/client";
|
|
4
4
|
export { GonvexClientError } from "@gonvex/client";
|
|
5
5
|
const GonvexContext = createContext(null);
|
|
6
6
|
const GonvexAuthContext = createContext({ isLoading: false, isAuthenticated: true });
|
|
7
|
-
export const ConvexProvider = GonvexProvider;
|
|
8
7
|
export function GonvexProvider(props) {
|
|
9
8
|
return _jsx(GonvexContext.Provider, { value: props.client, children: props.children });
|
|
10
9
|
}
|
|
11
|
-
export { ConvexReactClient };
|
|
12
10
|
class GonvexAuthRequestError extends Error {
|
|
13
11
|
status;
|
|
14
12
|
constructor(message, status) {
|
|
@@ -18,7 +16,7 @@ class GonvexAuthRequestError extends Error {
|
|
|
18
16
|
}
|
|
19
17
|
}
|
|
20
18
|
const ManagedAuthContext = createContext(null);
|
|
21
|
-
export function
|
|
19
|
+
export function GonvexProviderWithAuth(props) {
|
|
22
20
|
const auth = props.useAuth();
|
|
23
21
|
const [tokenReady, setTokenReady] = useState(false);
|
|
24
22
|
const [clientAuthError, setClientAuthError] = useState(null);
|
|
@@ -70,12 +68,7 @@ export function ConvexProviderWithAuth(props) {
|
|
|
70
68
|
const shouldHoldChildren = !authError && (auth.isLoading || (auth.isAuthenticated && !tokenReady));
|
|
71
69
|
return (_jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: shouldHoldChildren ? null : props.children }) }));
|
|
72
70
|
}
|
|
73
|
-
/**
|
|
74
|
-
* Native Gonvex authentication. The runtime performs the one centrally
|
|
75
|
-
* configured Google OAuth flow, while each app uses PKCE and receives a
|
|
76
|
-
* project-scoped Gonvex session. No Firebase or Google SDK is loaded in the
|
|
77
|
-
* browser.
|
|
78
|
-
*/
|
|
71
|
+
/** Native Gonvex authentication with password or a configured OAuth provider. */
|
|
79
72
|
// Dedupe callback bootstrap across React StrictMode remounts so the OAuth
|
|
80
73
|
// code+PKCE exchange runs once. Without this, the first effect's finally
|
|
81
74
|
// clears sessionStorage PKCE before the remount can finish verification.
|
|
@@ -89,27 +82,61 @@ export function GonvexAuthProvider(props) {
|
|
|
89
82
|
const [isLoading, setIsLoading] = useState(true);
|
|
90
83
|
const [error, setError] = useState(null);
|
|
91
84
|
const [refreshRetryAt, setRefreshRetryAt] = useState(0);
|
|
85
|
+
const [developerMode, setDeveloperMode] = useState({ active: false });
|
|
92
86
|
const sessionRef = useRef(session);
|
|
93
87
|
const refreshRef = useRef(null);
|
|
88
|
+
const developerModeRef = useRef(null);
|
|
94
89
|
const installSession = useCallback((next, persist = true) => {
|
|
95
90
|
sessionRef.current = next;
|
|
96
91
|
if (next) {
|
|
97
92
|
if (persist)
|
|
98
93
|
safeLocalStorageSet(storageKey, JSON.stringify(next));
|
|
99
|
-
|
|
94
|
+
if (!developerModeRef.current) {
|
|
95
|
+
props.client.setAuth({
|
|
96
|
+
project: props.projectId, tenant: next.activeTenantId, token: next.accessToken,
|
|
97
|
+
identity: { sub: next.account.id, iss: props.projectId },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
100
|
}
|
|
101
101
|
else {
|
|
102
102
|
if (persist)
|
|
103
103
|
safeLocalStorageRemove(storageKey);
|
|
104
|
-
|
|
104
|
+
if (!developerModeRef.current) {
|
|
105
|
+
props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined, identity: undefined });
|
|
106
|
+
}
|
|
105
107
|
}
|
|
106
108
|
setSession(next);
|
|
107
109
|
}, [props.client, props.projectId, storageKey]);
|
|
110
|
+
const restoreAccountSession = useCallback(() => {
|
|
111
|
+
const developer = developerModeRef.current;
|
|
112
|
+
if (!developer)
|
|
113
|
+
return;
|
|
114
|
+
developerModeRef.current = null;
|
|
115
|
+
setDeveloperMode({ active: false });
|
|
116
|
+
const current = sessionRef.current;
|
|
117
|
+
const activeTenantId = current?.tenants.some((tenant) => tenant.id === developer.originalTenantId)
|
|
118
|
+
? developer.originalTenantId
|
|
119
|
+
: current?.activeTenantId;
|
|
120
|
+
installSession(current ? { ...current, activeTenantId } : null);
|
|
121
|
+
}, [installSession]);
|
|
122
|
+
useEffect(() => props.client.onAuthError((message) => {
|
|
123
|
+
if (!developerModeRef.current)
|
|
124
|
+
return;
|
|
125
|
+
restoreAccountSession();
|
|
126
|
+
setError(message || "Developer mode ended because its authorization is no longer valid.");
|
|
127
|
+
}), [props.client, restoreAccountSession]);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!developerMode.active || !developerMode.expiresAt)
|
|
130
|
+
return;
|
|
131
|
+
const delay = Math.max(0, Date.parse(developerMode.expiresAt) - Date.now());
|
|
132
|
+
const timeout = window.setTimeout(() => restoreAccountSession(), delay);
|
|
133
|
+
return () => window.clearTimeout(timeout);
|
|
134
|
+
}, [developerMode.active, developerMode.expiresAt, restoreAccountSession]);
|
|
108
135
|
useEffect(() => {
|
|
109
136
|
let cancelled = false;
|
|
110
137
|
let bootstrap = authBootstrapPromises.get(storageKey);
|
|
111
138
|
if (!bootstrap) {
|
|
112
|
-
bootstrap = bootstrapGonvexAuth({ callbackPath, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
|
|
139
|
+
bootstrap = bootstrapGonvexAuth({ callbackPath, client: props.client, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
|
|
113
140
|
.finally(() => {
|
|
114
141
|
// Keep the resolved promise briefly so a StrictMode remount attaches
|
|
115
142
|
// to the same result instead of re-running a spent OAuth code.
|
|
@@ -127,7 +154,7 @@ export function GonvexAuthProvider(props) {
|
|
|
127
154
|
}).catch((cause) => {
|
|
128
155
|
if (!cancelled) {
|
|
129
156
|
installSession(null);
|
|
130
|
-
setError(cause instanceof Error ? cause.message : "
|
|
157
|
+
setError(cause instanceof Error ? cause.message : "Sign-in failed.");
|
|
131
158
|
}
|
|
132
159
|
}).finally(() => {
|
|
133
160
|
if (!cancelled)
|
|
@@ -146,12 +173,8 @@ export function GonvexAuthProvider(props) {
|
|
|
146
173
|
if (!force && current.expiresAt > Date.now() + 60_000)
|
|
147
174
|
return current;
|
|
148
175
|
attemptedRefreshToken = current.refreshToken;
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
project: props.projectId,
|
|
152
|
-
refreshToken: current.refreshToken,
|
|
153
|
-
tenant: current.activeTenantId,
|
|
154
|
-
});
|
|
176
|
+
const grant = await props.client.action(control.auth.refreshSession, { refreshToken: current.refreshToken });
|
|
177
|
+
const next = sessionFromNativeGrant(grant, current);
|
|
155
178
|
// Persist the rotated token before releasing the cross-tab lock. The
|
|
156
179
|
// next waiter must never read and reuse the just-consumed refresh token.
|
|
157
180
|
safeLocalStorageSet(storageKey, JSON.stringify(next));
|
|
@@ -192,7 +215,7 @@ export function GonvexAuthProvider(props) {
|
|
|
192
215
|
});
|
|
193
216
|
refreshRef.current = request;
|
|
194
217
|
return request;
|
|
195
|
-
}, [installSession, props.
|
|
218
|
+
}, [installSession, props.client, storageKey]);
|
|
196
219
|
useEffect(() => {
|
|
197
220
|
if (!session)
|
|
198
221
|
return;
|
|
@@ -201,6 +224,25 @@ export function GonvexAuthProvider(props) {
|
|
|
201
224
|
const timeout = window.setTimeout(() => { void refreshSession(); }, delay);
|
|
202
225
|
return () => window.clearTimeout(timeout);
|
|
203
226
|
}, [refreshRetryAt, refreshSession, session]);
|
|
227
|
+
// Keep the account tenant directory authoritative without a reducer+manual
|
|
228
|
+
// refetch pair. The live Control Plane Query resumes on reconnect.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!sessionRef.current)
|
|
231
|
+
return;
|
|
232
|
+
const watch = props.client.watchControlQuery(control.tenants.mine, {});
|
|
233
|
+
return watch.onUpdate(() => {
|
|
234
|
+
const tenants = watch.getSnapshot().result;
|
|
235
|
+
const current = sessionRef.current;
|
|
236
|
+
if (!current || !tenants)
|
|
237
|
+
return;
|
|
238
|
+
const activeTenantId = tenants.some((tenant) => tenant.id === current.activeTenantId)
|
|
239
|
+
? current.activeTenantId
|
|
240
|
+
: tenants[0]?.id;
|
|
241
|
+
if (JSON.stringify(current.tenants) === JSON.stringify(tenants) && current.activeTenantId === activeTenantId)
|
|
242
|
+
return;
|
|
243
|
+
installSession({ ...current, tenants, activeTenantId });
|
|
244
|
+
});
|
|
245
|
+
}, [installSession, props.client, session?.account.id]);
|
|
204
246
|
useEffect(() => {
|
|
205
247
|
const onStorage = (event) => {
|
|
206
248
|
if (event.key !== storageKey)
|
|
@@ -210,7 +252,7 @@ export function GonvexAuthProvider(props) {
|
|
|
210
252
|
window.addEventListener("storage", onStorage);
|
|
211
253
|
return () => window.removeEventListener("storage", onStorage);
|
|
212
254
|
}, [installSession, storageKey]);
|
|
213
|
-
const
|
|
255
|
+
const signInWithProvider = useCallback(async (provider) => {
|
|
214
256
|
setError(null);
|
|
215
257
|
const verifier = randomBase64Url(64);
|
|
216
258
|
const state = randomBase64Url(32);
|
|
@@ -218,8 +260,8 @@ export function GonvexAuthProvider(props) {
|
|
|
218
260
|
const challenge = bytesToBase64Url(new Uint8Array(challengeBytes));
|
|
219
261
|
const redirectUri = new URL(callbackPath, window.location.origin).toString();
|
|
220
262
|
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
221
|
-
safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, createdAt: Date.now() }));
|
|
222
|
-
const authorizeUrl = new URL(`${runtimeUrl}/auth/
|
|
263
|
+
safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, provider, createdAt: Date.now() }));
|
|
264
|
+
const authorizeUrl = new URL(`${runtimeUrl}/auth/${provider}/authorize`);
|
|
223
265
|
authorizeUrl.searchParams.set("project", props.projectId);
|
|
224
266
|
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
225
267
|
authorizeUrl.searchParams.set("state", state);
|
|
@@ -227,18 +269,21 @@ export function GonvexAuthProvider(props) {
|
|
|
227
269
|
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
228
270
|
window.location.assign(authorizeUrl.toString());
|
|
229
271
|
}, [callbackPath, pkceStorageKey, props.projectId, runtimeUrl]);
|
|
272
|
+
const signIn = useCallback((provider = "google") => signInWithProvider(provider), [signInWithProvider]);
|
|
273
|
+
const signInWithPassword = useCallback(async (email, password) => {
|
|
274
|
+
setError(null);
|
|
275
|
+
const grant = await props.client.action(control.auth.passwordLogin, { email, password });
|
|
276
|
+
const next = sessionFromNativeGrant(grant, sessionRef.current ?? undefined);
|
|
277
|
+
installSession(next);
|
|
278
|
+
}, [installSession, props.client]);
|
|
230
279
|
const signOut = useCallback(async (options) => {
|
|
231
280
|
const current = sessionRef.current;
|
|
232
|
-
installSession(null);
|
|
233
281
|
setError(null);
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
body: JSON.stringify({ refreshToken: current.refreshToken, all: options?.allDevices === true }),
|
|
240
|
-
}).catch(() => undefined);
|
|
241
|
-
}, [installSession, runtimeUrl]);
|
|
282
|
+
if (current) {
|
|
283
|
+
await props.client.reducer(control.auth.logout, { refreshToken: current.refreshToken, all: options?.allDevices === true }).catch(() => undefined);
|
|
284
|
+
}
|
|
285
|
+
installSession(null);
|
|
286
|
+
}, [installSession, props.client]);
|
|
242
287
|
const fetchAccessToken = useCallback(async (args) => {
|
|
243
288
|
const current = sessionRef.current;
|
|
244
289
|
if (!current)
|
|
@@ -248,6 +293,8 @@ export function GonvexAuthProvider(props) {
|
|
|
248
293
|
return (await refreshSession(args.forceRefreshToken))?.accessToken ?? null;
|
|
249
294
|
}, [refreshSession]);
|
|
250
295
|
const setActiveTenant = useCallback(async (tenantId) => {
|
|
296
|
+
if (developerModeRef.current)
|
|
297
|
+
throw new Error("Exit developer mode before switching tenants.");
|
|
251
298
|
const current = sessionRef.current;
|
|
252
299
|
if (!current || !current.tenants.some((tenant) => tenant.id === tenantId)) {
|
|
253
300
|
throw new Error(`Your account does not have access to tenant ${tenantId}.`);
|
|
@@ -259,82 +306,116 @@ export function GonvexAuthProvider(props) {
|
|
|
259
306
|
if (!token)
|
|
260
307
|
throw new Error("Sign in before loading tenant memberships.");
|
|
261
308
|
const current = sessionRef.current;
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
309
|
+
const [account, tenants] = await Promise.all([
|
|
310
|
+
props.client.query(control.accounts.me, {}),
|
|
311
|
+
props.client.query(control.tenants.mine, {}),
|
|
312
|
+
]);
|
|
313
|
+
const mappedAccount = { id: account.id, email: account.email, emailVerified: true, name: account.name, picture: account.avatarUrl, provider: current.account.provider };
|
|
314
|
+
const mappedTenants = tenants;
|
|
315
|
+
const activeTenantId = mappedTenants.some((tenant) => tenant.id === current.activeTenantId) ? current.activeTenantId : mappedTenants[0]?.id;
|
|
316
|
+
installSession({ ...current, account: mappedAccount, tenants: mappedTenants, activeTenantId });
|
|
317
|
+
return mappedTenants;
|
|
318
|
+
}, [fetchAccessToken, installSession, props.client]);
|
|
271
319
|
const createTenant = useCallback(async (name) => {
|
|
272
320
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
273
321
|
if (!token)
|
|
274
322
|
throw new Error("Sign in before creating a tenant.");
|
|
275
|
-
const
|
|
276
|
-
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
277
|
-
body: JSON.stringify({ name }),
|
|
278
|
-
});
|
|
279
|
-
const payload = await response.json().catch(() => ({}));
|
|
280
|
-
if (!response.ok || !payload.tenant)
|
|
281
|
-
throw new Error(payload.error ?? "Could not create the tenant.");
|
|
323
|
+
const tenant = await props.client.reducer(control.tenants.create, { name });
|
|
282
324
|
const current = sessionRef.current;
|
|
283
|
-
installSession({ ...current, tenants: [...current.tenants.filter((
|
|
284
|
-
return
|
|
285
|
-
}, [fetchAccessToken, installSession,
|
|
325
|
+
installSession({ ...current, tenants: [...current.tenants.filter((item) => item.id !== tenant.id), tenant], activeTenantId: tenant.id });
|
|
326
|
+
return tenant;
|
|
327
|
+
}, [fetchAccessToken, installSession, props.client]);
|
|
286
328
|
const inviteMember = useCallback(async (tenantId, email, options) => {
|
|
287
329
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
288
330
|
if (!token)
|
|
289
331
|
throw new Error("Sign in before inviting a member.");
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (!token)
|
|
301
|
-
throw new Error("Sign in before removing a member.");
|
|
302
|
-
const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`, {
|
|
303
|
-
method: "DELETE", headers: { authorization: `Bearer ${token}` },
|
|
304
|
-
});
|
|
305
|
-
const payload = await response.json().catch(() => ({}));
|
|
306
|
-
if (!response.ok)
|
|
307
|
-
throw new Error(payload.error ?? "Could not remove the member.");
|
|
308
|
-
}, [fetchAccessToken, runtimeUrl]);
|
|
332
|
+
if (tenantId !== sessionRef.current?.activeTenantId)
|
|
333
|
+
throw new Error("Switch to the tenant before inviting a member.");
|
|
334
|
+
return props.client.reducer(control.invitations.create, { email, role: options?.role ?? "member", permissions: (options?.permissions ?? {}), teamIds: options?.teamIds ?? [], allowedAuthProviders: options?.allowedAuthProviders ?? [], payload: options?.payload ?? {} });
|
|
335
|
+
}, [fetchAccessToken, props.client]);
|
|
336
|
+
const acceptInvitation = useCallback(async (token) => {
|
|
337
|
+
const accessToken = await fetchAccessToken({ forceRefreshToken: false });
|
|
338
|
+
if (!accessToken)
|
|
339
|
+
throw new Error("Sign in before accepting an invitation.");
|
|
340
|
+
return props.client.reducer(control.invitations.accept, { token });
|
|
341
|
+
}, [fetchAccessToken, props.client]);
|
|
309
342
|
const revokeInvitation = useCallback(async (tenantId, email) => {
|
|
310
343
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
311
344
|
if (!token)
|
|
312
345
|
throw new Error("Sign in before revoking an invitation.");
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
346
|
+
if (tenantId !== sessionRef.current?.activeTenantId)
|
|
347
|
+
throw new Error("Switch to the tenant before revoking an invitation.");
|
|
348
|
+
await props.client.reducer(control.invitations.revoke, { id: "", email });
|
|
349
|
+
}, [fetchAccessToken, props.client]);
|
|
350
|
+
const enterDeveloperMode = useCallback(async (tenantId) => {
|
|
351
|
+
const current = sessionRef.current;
|
|
352
|
+
if (!current)
|
|
353
|
+
throw new Error("Sign in before entering developer mode.");
|
|
354
|
+
if (developerModeRef.current)
|
|
355
|
+
throw new Error("Exit developer mode before entering another tenant.");
|
|
356
|
+
setError(null);
|
|
357
|
+
const grant = await props.client.reducer(control.developer.enter, { tenantId });
|
|
358
|
+
const expiresAt = String(grant.expiresAt);
|
|
359
|
+
if (!grant.id || !grant.token || !Number.isFinite(Date.parse(expiresAt))) {
|
|
360
|
+
throw new Error("Gonvex returned an invalid developer grant.");
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
await props.client.authenticate({
|
|
364
|
+
project: props.projectId,
|
|
365
|
+
tenant: tenantId,
|
|
366
|
+
token: grant.token,
|
|
367
|
+
fetchToken: undefined,
|
|
368
|
+
identity: { sub: current.account.id, iss: props.projectId },
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
catch (cause) {
|
|
372
|
+
props.client.setAuth({
|
|
373
|
+
project: props.projectId,
|
|
374
|
+
tenant: current.activeTenantId,
|
|
375
|
+
token: current.accessToken,
|
|
376
|
+
fetchToken: undefined,
|
|
377
|
+
identity: { sub: current.account.id, iss: props.projectId },
|
|
378
|
+
});
|
|
379
|
+
throw cause;
|
|
380
|
+
}
|
|
381
|
+
const next = { active: true, tenantId, grantId: grant.id, expiresAt, originalTenantId: current.activeTenantId };
|
|
382
|
+
developerModeRef.current = next;
|
|
383
|
+
setDeveloperMode({ active: true, tenantId, grantId: grant.id, expiresAt });
|
|
384
|
+
}, [props.client, props.projectId]);
|
|
385
|
+
const exitDeveloperMode = useCallback(async () => {
|
|
386
|
+
const developer = developerModeRef.current;
|
|
387
|
+
if (!developer?.grantId)
|
|
388
|
+
return;
|
|
389
|
+
setError(null);
|
|
390
|
+
// Remain in developer mode if revocation fails. Restoring first would leave
|
|
391
|
+
// an active grant detached from the provider's state.
|
|
392
|
+
await props.client.reducer(control.developer.exit, { grantId: developer.grantId });
|
|
393
|
+
restoreAccountSession();
|
|
394
|
+
}, [props.client, restoreAccountSession]);
|
|
395
|
+
const visibleTenantId = developerMode.active ? developerMode.tenantId : session?.activeTenantId;
|
|
396
|
+
const activeTenant = session?.tenants.find((tenant) => tenant.id === visibleTenantId) ?? null;
|
|
321
397
|
const authValue = useMemo(() => ({
|
|
322
398
|
isLoading,
|
|
323
399
|
isAuthenticated: Boolean(session && session.refreshExpiresAt > Date.now()),
|
|
324
400
|
fetchAccessToken,
|
|
325
|
-
|
|
401
|
+
account: session?.account ?? null,
|
|
326
402
|
tenants: session?.tenants ?? [],
|
|
327
403
|
activeTenant,
|
|
328
404
|
error,
|
|
329
405
|
signIn,
|
|
406
|
+
signInWithProvider,
|
|
407
|
+
signInWithPassword,
|
|
330
408
|
signOut,
|
|
331
409
|
setActiveTenant,
|
|
332
410
|
refreshMemberships,
|
|
333
411
|
createTenant,
|
|
334
412
|
inviteMember,
|
|
413
|
+
acceptInvitation,
|
|
335
414
|
revokeInvitation,
|
|
336
|
-
|
|
337
|
-
|
|
415
|
+
developerMode,
|
|
416
|
+
enterDeveloperMode,
|
|
417
|
+
exitDeveloperMode,
|
|
418
|
+
}), [acceptInvitation, activeTenant, createTenant, developerMode, enterDeveloperMode, error, exitDeveloperMode, fetchAccessToken, inviteMember, isLoading, refreshMemberships, revokeInvitation, session, setActiveTenant, signIn, signInWithPassword, signInWithProvider, signOut]);
|
|
338
419
|
return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? null : props.children }) }) }));
|
|
339
420
|
}
|
|
340
421
|
export function useGonvexAuth() {
|
|
@@ -343,6 +424,18 @@ export function useGonvexAuth() {
|
|
|
343
424
|
throw new Error("GonvexAuthProvider is required");
|
|
344
425
|
return value;
|
|
345
426
|
}
|
|
427
|
+
/** Subscribed profile for the active tenant, reconciled by GonvexAuthProvider. */
|
|
428
|
+
export function useCurrentTenantProfile() {
|
|
429
|
+
return useGonvexAuth().activeTenant;
|
|
430
|
+
}
|
|
431
|
+
/** Live tenant-admin invitation list; reducer changes reconcile automatically. */
|
|
432
|
+
export function useInvitationList() {
|
|
433
|
+
return useControlQuery(control.invitations.list, {});
|
|
434
|
+
}
|
|
435
|
+
/** Read the auth state installed by either auth provider. */
|
|
436
|
+
export function useGonvexAuthState() {
|
|
437
|
+
return useContext(GonvexAuthContext);
|
|
438
|
+
}
|
|
346
439
|
export function GonvexGoogleAuthButton(props) {
|
|
347
440
|
const { signOutLabel = "Sign out", children, disabled, onClick, ...buttonProps } = props;
|
|
348
441
|
const auth = useGonvexAuth();
|
|
@@ -386,10 +479,9 @@ async function bootstrapGonvexAuth(options) {
|
|
|
386
479
|
return latest;
|
|
387
480
|
if (latest.refreshExpiresAt <= Date.now())
|
|
388
481
|
throw new GonvexAuthRequestError("Your session expired. Please sign in again.", 401);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
});
|
|
482
|
+
options.client.setAuth({ project: options.projectId, token: latest.accessToken });
|
|
483
|
+
const grant = await options.client.action(control.auth.refreshSession, { refreshToken: latest.refreshToken });
|
|
484
|
+
const next = sessionFromNativeGrant(grant, latest);
|
|
393
485
|
safeLocalStorageSet(options.storageKey, JSON.stringify(next));
|
|
394
486
|
return next;
|
|
395
487
|
});
|
|
@@ -401,26 +493,34 @@ async function bootstrapGonvexAuth(options) {
|
|
|
401
493
|
}
|
|
402
494
|
}
|
|
403
495
|
const pkce = readPKCE(options.pkceStorageKey);
|
|
404
|
-
|
|
405
|
-
|
|
496
|
+
const provider = pkce?.provider ?? "google";
|
|
497
|
+
const providerLabel = provider[0].toUpperCase() + provider.slice(1);
|
|
498
|
+
// Surface the runtime error even when another tab already consumed PKCE.
|
|
406
499
|
if (callbackError) {
|
|
407
500
|
const messages = {
|
|
408
|
-
access_denied:
|
|
409
|
-
invitation_required:
|
|
501
|
+
access_denied: `${providerLabel} sign-in was cancelled.`,
|
|
502
|
+
invitation_required: `This app is invite-only. Ask an administrator to invite your verified ${providerLabel} email.`,
|
|
410
503
|
verified_google_email_required: "Google must provide a verified email address for this app.",
|
|
504
|
+
verified_microsoft_email_required: "Microsoft must provide a verified email address for this app.",
|
|
411
505
|
membership_setup_failed: "Your account was verified, but its workspace could not be prepared. Please try again.",
|
|
412
506
|
google_exchange_failed: "Google rejected the sign-in code exchange. Check GONVEX_GOOGLE_CLIENT_ID/SECRET and the broker callback URI.",
|
|
507
|
+
microsoft_exchange_failed: "Microsoft rejected the sign-in code exchange. Check the project's Microsoft realm configuration.",
|
|
508
|
+
apple_exchange_failed: "Apple rejected the sign-in code exchange. Check the project's Apple realm configuration.",
|
|
413
509
|
invalid_google_identity: "Google identity verification failed. Please try again.",
|
|
414
|
-
|
|
510
|
+
invalid_microsoft_identity: "Microsoft identity verification failed. Please try again.",
|
|
511
|
+
invalid_apple_identity: "Apple identity verification failed. Please try again.",
|
|
512
|
+
microsoft_not_configured: "Microsoft sign-in is not configured for this project.",
|
|
513
|
+
apple_not_configured: "Apple sign-in is not configured for this project.",
|
|
514
|
+
account_creation_failed: `Your ${providerLabel} account could not be linked. Please try again.`,
|
|
415
515
|
code_creation_failed: "Gonvex could not finish creating a sign-in code. Please try again.",
|
|
416
516
|
};
|
|
417
517
|
safeSessionStorageRemove(options.pkceStorageKey);
|
|
418
518
|
clearAuthCallbackParams(url, pkce?.returnTo);
|
|
419
|
-
throw new Error(messages[callbackError] ??
|
|
519
|
+
throw new Error(messages[callbackError] ?? `${providerLabel} sign-in failed (${callbackError}). Please try again.`);
|
|
420
520
|
}
|
|
421
521
|
if (!pkce || !returnedState || returnedState !== pkce.state || Date.now() - pkce.createdAt > 10 * 60 * 1000) {
|
|
422
522
|
clearAuthCallbackParams(url, pkce?.returnTo);
|
|
423
|
-
throw new Error(
|
|
523
|
+
throw new Error(`The ${providerLabel} sign-in response could not be verified. Please try again.`);
|
|
424
524
|
}
|
|
425
525
|
// Consume PKCE only after validation so a concurrent remount still sees it.
|
|
426
526
|
safeSessionStorageRemove(options.pkceStorageKey);
|
|
@@ -434,6 +534,16 @@ async function bootstrapGonvexAuth(options) {
|
|
|
434
534
|
safeLocalStorageSet(options.storageKey, JSON.stringify(session));
|
|
435
535
|
return session;
|
|
436
536
|
}
|
|
537
|
+
function sessionFromNativeGrant(value, previous) {
|
|
538
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !isGonvexAuthSession(value)) {
|
|
539
|
+
throw new GonvexAuthRequestError("Gonvex returned an invalid native session.", 502);
|
|
540
|
+
}
|
|
541
|
+
const session = value;
|
|
542
|
+
const activeTenantId = previous && session.tenants.some((tenant) => tenant.id === previous.activeTenantId)
|
|
543
|
+
? previous.activeTenantId
|
|
544
|
+
: session.activeTenantId;
|
|
545
|
+
return { ...session, activeTenantId };
|
|
546
|
+
}
|
|
437
547
|
async function requestGonvexAuthToken(runtimeUrl, body) {
|
|
438
548
|
const controller = new AbortController();
|
|
439
549
|
const timeout = window.setTimeout(() => controller.abort(), 15_000);
|
|
@@ -461,7 +571,7 @@ function isFatalRefreshError(cause) {
|
|
|
461
571
|
}
|
|
462
572
|
function isGonvexAuthSession(value) {
|
|
463
573
|
return Boolean(value.accessToken && value.expiresAt && value.refreshToken && value.refreshExpiresAt
|
|
464
|
-
&& value.
|
|
574
|
+
&& value.account?.id && Array.isArray(value.tenants));
|
|
465
575
|
}
|
|
466
576
|
async function withBrowserAuthLock(name, action) {
|
|
467
577
|
const locks = typeof navigator === "undefined"
|
|
@@ -534,7 +644,7 @@ function readAuthSession(key) {
|
|
|
534
644
|
return null;
|
|
535
645
|
try {
|
|
536
646
|
const parsed = JSON.parse(localStorage.getItem(key) ?? "null");
|
|
537
|
-
if (!parsed?.accessToken || !parsed.refreshToken || !parsed.
|
|
647
|
+
if (!parsed?.accessToken || !parsed.refreshToken || !parsed.account?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
|
|
538
648
|
safeLocalStorageRemove(key);
|
|
539
649
|
return null;
|
|
540
650
|
}
|
|
@@ -575,11 +685,7 @@ function bytesToBase64Url(bytes) {
|
|
|
575
685
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
576
686
|
}
|
|
577
687
|
const DEFAULT_LIVE_QUERY_SLOW_MS = 15_000;
|
|
578
|
-
/**
|
|
579
|
-
* Live query hook with an explicit status surface. Unlike `useQuery`, this
|
|
580
|
-
* distinguishes loading / success / error / timeout / disconnected, keeps the
|
|
581
|
-
* last good result while reconnecting, and exposes `retry()`.
|
|
582
|
-
*/
|
|
688
|
+
/** One-shot Query hook with explicit loading/error/timeout status and retry. */
|
|
583
689
|
export function useQueryResult(ref, args = {}, options = {}) {
|
|
584
690
|
const client = useGonvexClient();
|
|
585
691
|
const path = ref.path;
|
|
@@ -588,6 +694,7 @@ export function useQueryResult(ref, args = {}, options = {}) {
|
|
|
588
694
|
const argsKey = JSON.stringify(args);
|
|
589
695
|
const keepPreviousData = options.keepPreviousData !== false;
|
|
590
696
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LIVE_QUERY_SLOW_MS;
|
|
697
|
+
const [requestGeneration, setRequestGeneration] = useState(0);
|
|
591
698
|
const [state, setState] = useState({
|
|
592
699
|
data: undefined,
|
|
593
700
|
status: args === "skip" ? "skip" : "loading",
|
|
@@ -619,147 +726,183 @@ export function useQueryResult(ref, args = {}, options = {}) {
|
|
|
619
726
|
setState({ data: undefined, status: "skip", error: null, isStale: false });
|
|
620
727
|
return;
|
|
621
728
|
}
|
|
622
|
-
setState(
|
|
729
|
+
setState((previous) => ({
|
|
730
|
+
data: keepPreviousData ? previous.data : undefined,
|
|
731
|
+
status: "loading",
|
|
732
|
+
error: null,
|
|
733
|
+
isStale: keepPreviousData && previous.data !== undefined,
|
|
734
|
+
}));
|
|
623
735
|
startSlowTimer();
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
});
|
|
628
|
-
const unsubscribeQuery = client.subscribeQuery(ref, args, (message) => {
|
|
629
|
-
if (message.type === "query.result") {
|
|
630
|
-
clearSlowTimer();
|
|
631
|
-
setState({ data: message.result, status: "success", error: null, isStale: false });
|
|
632
|
-
}
|
|
633
|
-
if (message.type === "query.error") {
|
|
634
|
-
clearSlowTimer();
|
|
635
|
-
const error = new GonvexClientError(message.error, { code: "server", path, operation: "query" });
|
|
636
|
-
setState((previous) => ({
|
|
637
|
-
data: keepPreviousData ? previous.data : undefined,
|
|
638
|
-
status: "error",
|
|
639
|
-
error,
|
|
640
|
-
isStale: keepPreviousData && previous.data !== undefined,
|
|
641
|
-
}));
|
|
642
|
-
}
|
|
643
|
-
});
|
|
644
|
-
const applyConnection = (connection) => {
|
|
645
|
-
if (!connection.isWebSocketConnected) {
|
|
646
|
-
clearSlowTimer();
|
|
647
|
-
setState((previous) => {
|
|
648
|
-
if (previous.status === "skip")
|
|
649
|
-
return previous;
|
|
650
|
-
return {
|
|
651
|
-
data: keepPreviousData ? previous.data : undefined,
|
|
652
|
-
status: "disconnected",
|
|
653
|
-
error: previous.error,
|
|
654
|
-
isStale: keepPreviousData && previous.data !== undefined,
|
|
655
|
-
};
|
|
656
|
-
});
|
|
736
|
+
let active = true;
|
|
737
|
+
void client.query(ref, args).then((data) => {
|
|
738
|
+
if (!active)
|
|
657
739
|
return;
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
})()
|
|
675
|
-
: undefined;
|
|
740
|
+
clearSlowTimer();
|
|
741
|
+
setState({ data, status: "success", error: null, isStale: false });
|
|
742
|
+
}, (failure) => {
|
|
743
|
+
if (!active)
|
|
744
|
+
return;
|
|
745
|
+
clearSlowTimer();
|
|
746
|
+
const error = failure instanceof Error
|
|
747
|
+
? failure
|
|
748
|
+
: new GonvexClientError(String(failure), { code: "server", path, operation: "query" });
|
|
749
|
+
setState((previous) => ({
|
|
750
|
+
data: keepPreviousData ? previous.data : undefined,
|
|
751
|
+
status: "error",
|
|
752
|
+
error,
|
|
753
|
+
isStale: keepPreviousData && previous.data !== undefined,
|
|
754
|
+
}));
|
|
755
|
+
});
|
|
676
756
|
return () => {
|
|
757
|
+
active = false;
|
|
677
758
|
clearSlowTimer();
|
|
678
|
-
unsubscribeScope();
|
|
679
|
-
unsubscribeQuery();
|
|
680
|
-
unsubscribeConnection?.();
|
|
681
759
|
};
|
|
682
760
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
683
|
-
}, [client, kind, path, optimisticKey, argsKey, keepPreviousData, startSlowTimer, clearSlowTimer]);
|
|
761
|
+
}, [client, kind, path, optimisticKey, argsKey, keepPreviousData, requestGeneration, startSlowTimer, clearSlowTimer]);
|
|
684
762
|
const retry = useCallback(() => {
|
|
685
763
|
if (args === "skip")
|
|
686
764
|
return;
|
|
687
|
-
|
|
688
|
-
data: previous.data,
|
|
689
|
-
status: "loading",
|
|
690
|
-
error: null,
|
|
691
|
-
isStale: previous.data !== undefined,
|
|
692
|
-
}));
|
|
693
|
-
startSlowTimer();
|
|
694
|
-
if (typeof client.retryQuery === "function") {
|
|
695
|
-
client.retryQuery(ref, JSON.parse(argsKey));
|
|
696
|
-
}
|
|
765
|
+
setRequestGeneration((generation) => generation + 1);
|
|
697
766
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
698
|
-
}, [
|
|
767
|
+
}, [argsKey]);
|
|
699
768
|
return {
|
|
700
769
|
data: state.data,
|
|
701
770
|
status: state.status,
|
|
702
771
|
error: state.error,
|
|
703
772
|
isLoading: state.status === "loading",
|
|
704
|
-
isError: state.status === "error" || state.status === "timeout"
|
|
773
|
+
isError: state.status === "error" || state.status === "timeout",
|
|
705
774
|
isSuccess: state.status === "success",
|
|
706
775
|
isStale: state.isStale,
|
|
707
776
|
retry,
|
|
708
777
|
};
|
|
709
778
|
}
|
|
710
|
-
export function
|
|
779
|
+
export function useLiveQuery(ref, args = {}) {
|
|
711
780
|
const client = useGonvexClient();
|
|
712
|
-
|
|
713
|
-
|
|
781
|
+
if (ref.delivery !== "live" || !ref.live?.plan) {
|
|
782
|
+
throw new Error(`useLiveQuery requires a structured Live Query reference: ${ref.path}`);
|
|
783
|
+
}
|
|
714
784
|
const path = ref.path;
|
|
715
785
|
const kind = ref.kind;
|
|
716
786
|
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
717
787
|
const argsKey = JSON.stringify(args);
|
|
788
|
+
const liveKey = JSON.stringify(ref.live ?? null);
|
|
789
|
+
const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args),
|
|
790
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
791
|
+
[client, kind, path, optimisticKey, argsKey, liveKey]);
|
|
792
|
+
const liveResult = useSyncExternalStore(useCallback((notify) => liveWatch?.onUpdate(notify) ?? (() => undefined), [liveWatch]), useCallback(() => liveWatch?.localLiveQueryResult(), [liveWatch]), () => undefined);
|
|
793
|
+
return liveResult;
|
|
794
|
+
}
|
|
795
|
+
/** Subscribe to a host-owned Control Plane Query on the existing Gonvex connection. */
|
|
796
|
+
export function useControlQuery(ref, args = {}) {
|
|
797
|
+
const client = useGonvexClient();
|
|
798
|
+
const argsKey = JSON.stringify(args);
|
|
799
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchControlQuery(ref, args),
|
|
800
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
801
|
+
[client, ref.kind, ref.path, argsKey]);
|
|
802
|
+
const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined);
|
|
803
|
+
return snapshot?.result;
|
|
804
|
+
}
|
|
805
|
+
/** Read one normalized entity from the single Gonvex Local Replica. */
|
|
806
|
+
export function useEntity(entity, id) {
|
|
807
|
+
const client = useGonvexClient();
|
|
808
|
+
useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
|
|
809
|
+
return client.localReplica.entity(entity, id);
|
|
810
|
+
}
|
|
811
|
+
/** Resolve an ordered entity batch with one Local Replica subscription. */
|
|
812
|
+
export function useReplicaEntities(entity, ids) {
|
|
813
|
+
const client = useGonvexClient();
|
|
814
|
+
const idsKey = JSON.stringify(ids);
|
|
815
|
+
const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
|
|
816
|
+
return useMemo(() => client.replicaEntities(entity, ids), [client, entity, idsKey, version]);
|
|
817
|
+
}
|
|
818
|
+
/** Read a persisted Live Query window without opening another server subscription. */
|
|
819
|
+
export function useRetainedLiveQuery(signatureOrReference, args = {}) {
|
|
820
|
+
const client = useGonvexClient();
|
|
821
|
+
const argsKey = JSON.stringify(args);
|
|
822
|
+
const signature = typeof signatureOrReference === "string"
|
|
823
|
+
? signatureOrReference
|
|
824
|
+
: client.replicaSignature(signatureOrReference, args);
|
|
825
|
+
const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
|
|
826
|
+
return useMemo(() => client.retainedLiveQuery(signature), [client, signature, argsKey, version]);
|
|
827
|
+
}
|
|
828
|
+
/** Structured Live Query state backed by normalized Local Replica entities. */
|
|
829
|
+
export function useLiveQueryState(ref, args = {}) {
|
|
830
|
+
const client = useGonvexClient();
|
|
831
|
+
const argsKey = JSON.stringify(args);
|
|
832
|
+
const signature = args === "skip" ? "" : client.replicaSignature(ref, args);
|
|
718
833
|
useEffect(() => {
|
|
834
|
+
if (args === "skip")
|
|
835
|
+
return;
|
|
836
|
+
return client.subscribeLiveQuery(ref, args, () => undefined);
|
|
837
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
838
|
+
}, [client, ref.kind, ref.path, argsKey]);
|
|
839
|
+
useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
|
|
840
|
+
if (args !== "skip" && client.localReplica.freshness() === "offline") {
|
|
841
|
+
const offline = client.offlineLiveQuery(ref, args);
|
|
842
|
+
return {
|
|
843
|
+
rows: offline.rows,
|
|
844
|
+
ids: offline.rows.map((row) => String(row.id ?? row._id ?? "")).filter(Boolean),
|
|
845
|
+
...(offline.total === undefined ? {} : { total: offline.total }),
|
|
846
|
+
...(offline.offset === undefined ? {} : { offset: offline.offset }),
|
|
847
|
+
...(offline.limit === undefined ? {} : { limit: offline.limit }),
|
|
848
|
+
source: "cache",
|
|
849
|
+
completeness: offline.completeness,
|
|
850
|
+
freshness: "offline",
|
|
851
|
+
supported: offline.supported,
|
|
852
|
+
...(offline.unsupportedOperator ? { unsupportedOperator: offline.unsupportedOperator } : {}),
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
return signature
|
|
856
|
+
? client.localReplica.liveQuery(signature)
|
|
857
|
+
: { rows: [], ids: [], source: "cache", completeness: "partial", freshness: client.localReplica.freshness() };
|
|
858
|
+
}
|
|
859
|
+
/** Execute a read-only Query once. Queries never subscribe or rerun. */
|
|
860
|
+
export function useQuery(ref, args = {}) {
|
|
861
|
+
const client = useGonvexClient();
|
|
862
|
+
const [result, setResult] = useState();
|
|
863
|
+
const [error, setError] = useState(null);
|
|
864
|
+
const argsKey = JSON.stringify(args);
|
|
865
|
+
useEffect(() => {
|
|
866
|
+
let active = true;
|
|
719
867
|
if (args === "skip") {
|
|
720
868
|
setResult(undefined);
|
|
721
869
|
setError(null);
|
|
722
|
-
return;
|
|
870
|
+
return () => { active = false; };
|
|
723
871
|
}
|
|
724
872
|
setResult(undefined);
|
|
725
873
|
setError(null);
|
|
726
|
-
|
|
727
|
-
setResult(
|
|
728
|
-
setError(
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
setResult(message.result);
|
|
733
|
-
setError(null);
|
|
734
|
-
}
|
|
735
|
-
if (message.type === "query.error") {
|
|
736
|
-
setResult(undefined);
|
|
737
|
-
setError(new GonvexClientError(message.error, { code: "server", path, operation: "query" }));
|
|
738
|
-
}
|
|
739
|
-
});
|
|
740
|
-
return () => {
|
|
741
|
-
unsubscribeScope();
|
|
742
|
-
unsubscribeQuery();
|
|
743
|
-
};
|
|
744
|
-
}, [client, kind, path, optimisticKey, argsKey]);
|
|
745
|
-
// Convex-compatible: a failed query throws during render so error
|
|
746
|
-
// boundaries can catch it, instead of being indistinguishable from loading.
|
|
874
|
+
void client.query(ref, args).then((value) => { if (active)
|
|
875
|
+
setResult(value); }, (failure) => { if (active)
|
|
876
|
+
setError(failure instanceof Error ? failure : new Error(String(failure))); });
|
|
877
|
+
return () => { active = false; };
|
|
878
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
879
|
+
}, [client, ref.kind, ref.path, argsKey]);
|
|
747
880
|
if (error)
|
|
748
881
|
throw error;
|
|
749
882
|
return result;
|
|
750
883
|
}
|
|
751
|
-
export function
|
|
884
|
+
export function useReplicaCollection(ref, args = {}) {
|
|
752
885
|
const client = useGonvexClient();
|
|
753
886
|
const path = ref.path;
|
|
754
887
|
const kind = ref.kind;
|
|
755
888
|
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
756
889
|
const argsKey = JSON.stringify(args);
|
|
757
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.
|
|
890
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
758
891
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
759
892
|
[client, kind, path, optimisticKey, argsKey]);
|
|
760
|
-
return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.
|
|
893
|
+
return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaResult(), [watch]), () => undefined);
|
|
761
894
|
}
|
|
762
|
-
|
|
895
|
+
/** Replica rows plus authoritative completeness, truncation, and freshness metadata. */
|
|
896
|
+
export function useReplicaCollectionState(ref, args = {}) {
|
|
897
|
+
const client = useGonvexClient();
|
|
898
|
+
const path = ref.path;
|
|
899
|
+
const argsKey = JSON.stringify(args);
|
|
900
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
901
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
902
|
+
[client, ref.kind, path, argsKey]);
|
|
903
|
+
return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaState(), [watch]), () => undefined);
|
|
904
|
+
}
|
|
905
|
+
export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
|
|
763
906
|
const client = useGonvexClient();
|
|
764
907
|
const path = ref.path;
|
|
765
908
|
const kind = ref.kind;
|
|
@@ -769,7 +912,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
769
912
|
const equalityRef = useRef(isEqual);
|
|
770
913
|
selectorRef.current = selector;
|
|
771
914
|
equalityRef.current = isEqual;
|
|
772
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.
|
|
915
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
773
916
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
774
917
|
[client, kind, path, optimisticKey, argsKey]);
|
|
775
918
|
const selectedRef = useRef({
|
|
@@ -780,7 +923,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
780
923
|
selectedRef.current = { initialized: false, value: undefined };
|
|
781
924
|
}, [watch]);
|
|
782
925
|
const getSnapshot = useCallback(() => {
|
|
783
|
-
const rows = watch?.
|
|
926
|
+
const rows = watch?.localReplicaResult();
|
|
784
927
|
const next = rows === undefined ? undefined : selectorRef.current(rows);
|
|
785
928
|
if (!selectedRef.current.initialized
|
|
786
929
|
|| next === undefined
|
|
@@ -795,7 +938,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
795
938
|
return () => undefined;
|
|
796
939
|
return watch.onUpdate(() => {
|
|
797
940
|
const previous = selectedRef.current.value;
|
|
798
|
-
const rows = watch.
|
|
941
|
+
const rows = watch.localReplicaResult();
|
|
799
942
|
const next = rows === undefined ? undefined : selectorRef.current(rows);
|
|
800
943
|
if (selectedRef.current.initialized
|
|
801
944
|
&& previous !== undefined
|
|
@@ -808,31 +951,25 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
808
951
|
}, [watch]);
|
|
809
952
|
return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
|
|
810
953
|
}
|
|
811
|
-
export function
|
|
954
|
+
export function useReducer(ref, options = {}) {
|
|
812
955
|
const client = useGonvexClient();
|
|
813
|
-
return (args = {}) => client.
|
|
956
|
+
return (args = {}) => client.reducer(ref, args, options);
|
|
814
957
|
}
|
|
815
958
|
export function useAction(ref, options = {}) {
|
|
816
959
|
const client = useGonvexClient();
|
|
817
960
|
return (args = {}) => client.action(ref, args, options);
|
|
818
961
|
}
|
|
819
|
-
export function useConvex() {
|
|
820
|
-
return useGonvexClient();
|
|
821
|
-
}
|
|
822
|
-
export function useConvexAuth() {
|
|
823
|
-
return useContext(GonvexAuthContext);
|
|
824
|
-
}
|
|
825
962
|
const FALLBACK_CONNECTION_STATE = {
|
|
826
963
|
isWebSocketConnected: false,
|
|
827
964
|
hasEverConnected: false,
|
|
828
965
|
connectionCount: 0,
|
|
829
966
|
connectionRetries: 0,
|
|
830
967
|
hasInflightRequests: false,
|
|
831
|
-
|
|
968
|
+
inflightReducers: 0,
|
|
832
969
|
inflightActions: 0,
|
|
833
970
|
inflightOneShotQueries: 0,
|
|
834
971
|
};
|
|
835
|
-
export function
|
|
972
|
+
export function useGonvexConnectionState() {
|
|
836
973
|
const client = useGonvexClient();
|
|
837
974
|
const [state, setState] = useState(() => (typeof client.connectionState === "function" ? client.connectionState() : FALLBACK_CONNECTION_STATE));
|
|
838
975
|
useEffect(() => {
|
|
@@ -843,18 +980,7 @@ export function useConvexConnectionState() {
|
|
|
843
980
|
}, [client]);
|
|
844
981
|
return state;
|
|
845
982
|
}
|
|
846
|
-
export function
|
|
847
|
-
const pageArgs = args === "skip" ? "skip" : { ...(isRecord(args) ? args : { args }), paginationOpts: { numItems: options.initialNumItems ?? 25, cursor: null } };
|
|
848
|
-
const result = useQuery(ref, pageArgs);
|
|
849
|
-
const page = Array.isArray(result) ? { page: result, isDone: true, continueCursor: null } : result;
|
|
850
|
-
return {
|
|
851
|
-
results: (page?.page ?? []),
|
|
852
|
-
status: args === "skip" ? "Exhausted" : result === undefined ? "LoadingFirstPage" : page?.isDone ? "Exhausted" : "CanLoadMore",
|
|
853
|
-
isLoading: args !== "skip" && result === undefined,
|
|
854
|
-
loadMore: (_numItems) => undefined,
|
|
855
|
-
};
|
|
856
|
-
}
|
|
857
|
-
function useGonvexClient() {
|
|
983
|
+
export function useGonvexClient() {
|
|
858
984
|
const client = useContext(GonvexContext);
|
|
859
985
|
if (!client)
|
|
860
986
|
throw new Error("GonvexProvider is required");
|