@gonvex/react 0.1.32 → 0.3.0

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/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 { ConvexReactClient, GonvexClientError } from "@gonvex/client";
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 ConvexProviderWithAuth(props) {
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.
@@ -96,12 +89,15 @@ export function GonvexAuthProvider(props) {
96
89
  if (next) {
97
90
  if (persist)
98
91
  safeLocalStorageSet(storageKey, JSON.stringify(next));
99
- props.client.setAuth({ project: props.projectId, tenant: next.activeTenantId, token: next.accessToken });
92
+ props.client.setAuth({
93
+ project: props.projectId, tenant: next.activeTenantId, token: next.accessToken,
94
+ identity: { sub: next.account.id, iss: props.projectId },
95
+ });
100
96
  }
101
97
  else {
102
98
  if (persist)
103
99
  safeLocalStorageRemove(storageKey);
104
- props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined });
100
+ props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined, identity: undefined });
105
101
  }
106
102
  setSession(next);
107
103
  }, [props.client, props.projectId, storageKey]);
@@ -109,7 +105,7 @@ export function GonvexAuthProvider(props) {
109
105
  let cancelled = false;
110
106
  let bootstrap = authBootstrapPromises.get(storageKey);
111
107
  if (!bootstrap) {
112
- bootstrap = bootstrapGonvexAuth({ callbackPath, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
108
+ bootstrap = bootstrapGonvexAuth({ callbackPath, client: props.client, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
113
109
  .finally(() => {
114
110
  // Keep the resolved promise briefly so a StrictMode remount attaches
115
111
  // to the same result instead of re-running a spent OAuth code.
@@ -127,7 +123,7 @@ export function GonvexAuthProvider(props) {
127
123
  }).catch((cause) => {
128
124
  if (!cancelled) {
129
125
  installSession(null);
130
- setError(cause instanceof Error ? cause.message : "Google sign-in failed.");
126
+ setError(cause instanceof Error ? cause.message : "Sign-in failed.");
131
127
  }
132
128
  }).finally(() => {
133
129
  if (!cancelled)
@@ -146,12 +142,8 @@ export function GonvexAuthProvider(props) {
146
142
  if (!force && current.expiresAt > Date.now() + 60_000)
147
143
  return current;
148
144
  attemptedRefreshToken = current.refreshToken;
149
- const next = await requestGonvexAuthToken(runtimeUrl, {
150
- grantType: "refresh_token",
151
- project: props.projectId,
152
- refreshToken: current.refreshToken,
153
- tenant: current.activeTenantId,
154
- });
145
+ const grant = await props.client.action(control.auth.refreshSession, { refreshToken: current.refreshToken });
146
+ const next = sessionFromNativeGrant(grant, current);
155
147
  // Persist the rotated token before releasing the cross-tab lock. The
156
148
  // next waiter must never read and reuse the just-consumed refresh token.
157
149
  safeLocalStorageSet(storageKey, JSON.stringify(next));
@@ -192,7 +184,7 @@ export function GonvexAuthProvider(props) {
192
184
  });
193
185
  refreshRef.current = request;
194
186
  return request;
195
- }, [installSession, props.projectId, runtimeUrl, storageKey]);
187
+ }, [installSession, props.client, storageKey]);
196
188
  useEffect(() => {
197
189
  if (!session)
198
190
  return;
@@ -201,6 +193,25 @@ export function GonvexAuthProvider(props) {
201
193
  const timeout = window.setTimeout(() => { void refreshSession(); }, delay);
202
194
  return () => window.clearTimeout(timeout);
203
195
  }, [refreshRetryAt, refreshSession, session]);
196
+ // Keep the account tenant directory authoritative without a reducer+manual
197
+ // refetch pair. The live Control Plane Query resumes on reconnect.
198
+ useEffect(() => {
199
+ if (!sessionRef.current)
200
+ return;
201
+ const watch = props.client.watchControlQuery(control.tenants.mine, {});
202
+ return watch.onUpdate(() => {
203
+ const tenants = watch.getSnapshot().result;
204
+ const current = sessionRef.current;
205
+ if (!current || !tenants)
206
+ return;
207
+ const activeTenantId = tenants.some((tenant) => tenant.id === current.activeTenantId)
208
+ ? current.activeTenantId
209
+ : tenants[0]?.id;
210
+ if (JSON.stringify(current.tenants) === JSON.stringify(tenants) && current.activeTenantId === activeTenantId)
211
+ return;
212
+ installSession({ ...current, tenants, activeTenantId });
213
+ });
214
+ }, [installSession, props.client, session?.account.id]);
204
215
  useEffect(() => {
205
216
  const onStorage = (event) => {
206
217
  if (event.key !== storageKey)
@@ -210,7 +221,7 @@ export function GonvexAuthProvider(props) {
210
221
  window.addEventListener("storage", onStorage);
211
222
  return () => window.removeEventListener("storage", onStorage);
212
223
  }, [installSession, storageKey]);
213
- const signIn = useCallback(async () => {
224
+ const signInWithProvider = useCallback(async (provider) => {
214
225
  setError(null);
215
226
  const verifier = randomBase64Url(64);
216
227
  const state = randomBase64Url(32);
@@ -218,8 +229,8 @@ export function GonvexAuthProvider(props) {
218
229
  const challenge = bytesToBase64Url(new Uint8Array(challengeBytes));
219
230
  const redirectUri = new URL(callbackPath, window.location.origin).toString();
220
231
  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/google/authorize`);
232
+ safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, provider, createdAt: Date.now() }));
233
+ const authorizeUrl = new URL(`${runtimeUrl}/auth/${provider}/authorize`);
223
234
  authorizeUrl.searchParams.set("project", props.projectId);
224
235
  authorizeUrl.searchParams.set("redirect_uri", redirectUri);
225
236
  authorizeUrl.searchParams.set("state", state);
@@ -227,18 +238,21 @@ export function GonvexAuthProvider(props) {
227
238
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
228
239
  window.location.assign(authorizeUrl.toString());
229
240
  }, [callbackPath, pkceStorageKey, props.projectId, runtimeUrl]);
241
+ const signIn = useCallback((provider = "google") => signInWithProvider(provider), [signInWithProvider]);
242
+ const signInWithPassword = useCallback(async (email, password) => {
243
+ setError(null);
244
+ const grant = await props.client.action(control.auth.passwordLogin, { email, password });
245
+ const next = sessionFromNativeGrant(grant, sessionRef.current ?? undefined);
246
+ installSession(next);
247
+ }, [installSession, props.client]);
230
248
  const signOut = useCallback(async (options) => {
231
249
  const current = sessionRef.current;
232
- installSession(null);
233
250
  setError(null);
234
- if (!current)
235
- return;
236
- await fetch(`${runtimeUrl}/auth/logout`, {
237
- method: "POST",
238
- headers: { authorization: `Bearer ${current.accessToken}`, "content-type": "application/json" },
239
- body: JSON.stringify({ refreshToken: current.refreshToken, all: options?.allDevices === true }),
240
- }).catch(() => undefined);
241
- }, [installSession, runtimeUrl]);
251
+ if (current) {
252
+ await props.client.reducer(control.auth.logout, { refreshToken: current.refreshToken, all: options?.allDevices === true }).catch(() => undefined);
253
+ }
254
+ installSession(null);
255
+ }, [installSession, props.client]);
242
256
  const fetchAccessToken = useCallback(async (args) => {
243
257
  const current = sessionRef.current;
244
258
  if (!current)
@@ -259,82 +273,67 @@ export function GonvexAuthProvider(props) {
259
273
  if (!token)
260
274
  throw new Error("Sign in before loading tenant memberships.");
261
275
  const current = sessionRef.current;
262
- const response = await fetch(`${runtimeUrl}/auth/me`, {
263
- headers: { authorization: `Bearer ${token}`, ...(current.activeTenantId ? { "x-gonvex-tenant-id": current.activeTenantId } : {}) },
264
- });
265
- const payload = await response.json().catch(() => ({}));
266
- if (!response.ok || !payload.user || !payload.tenants)
267
- throw new Error(payload.error ?? "Could not load tenant memberships.");
268
- installSession({ ...current, user: payload.user, tenants: payload.tenants, activeTenantId: payload.activeTenantId });
269
- return payload.tenants;
270
- }, [fetchAccessToken, installSession, runtimeUrl]);
276
+ const [account, tenants] = await Promise.all([
277
+ props.client.query(control.accounts.me, {}),
278
+ props.client.query(control.tenants.mine, {}),
279
+ ]);
280
+ const mappedAccount = { id: account.id, email: account.email, emailVerified: true, name: account.name, picture: account.avatarUrl, provider: current.account.provider };
281
+ const mappedTenants = tenants;
282
+ const activeTenantId = mappedTenants.some((tenant) => tenant.id === current.activeTenantId) ? current.activeTenantId : mappedTenants[0]?.id;
283
+ installSession({ ...current, account: mappedAccount, tenants: mappedTenants, activeTenantId });
284
+ return mappedTenants;
285
+ }, [fetchAccessToken, installSession, props.client]);
271
286
  const createTenant = useCallback(async (name) => {
272
287
  const token = await fetchAccessToken({ forceRefreshToken: false });
273
288
  if (!token)
274
289
  throw new Error("Sign in before creating a tenant.");
275
- const response = await fetch(`${runtimeUrl}/auth/tenants`, {
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.");
290
+ const tenant = await props.client.reducer(control.tenants.create, { name });
282
291
  const current = sessionRef.current;
283
- installSession({ ...current, tenants: [...current.tenants.filter((tenant) => tenant.id !== payload.tenant.id), payload.tenant], activeTenantId: payload.tenant.id });
284
- return payload.tenant;
285
- }, [fetchAccessToken, installSession, runtimeUrl]);
292
+ installSession({ ...current, tenants: [...current.tenants.filter((item) => item.id !== tenant.id), tenant], activeTenantId: tenant.id });
293
+ return tenant;
294
+ }, [fetchAccessToken, installSession, props.client]);
286
295
  const inviteMember = useCallback(async (tenantId, email, options) => {
287
296
  const token = await fetchAccessToken({ forceRefreshToken: false });
288
297
  if (!token)
289
298
  throw new Error("Sign in before inviting a member.");
290
- const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/members`, {
291
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
292
- body: JSON.stringify({ email, role: options?.role ?? "member", permissions: options?.permissions ?? {} }),
293
- });
294
- const payload = await response.json().catch(() => ({}));
295
- if (!response.ok)
296
- throw new Error(payload.error ?? "Could not invite the member.");
297
- }, [fetchAccessToken, runtimeUrl]);
298
- const removeMember = useCallback(async (tenantId, userId) => {
299
- const token = await fetchAccessToken({ forceRefreshToken: false });
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]);
299
+ if (tenantId !== sessionRef.current?.activeTenantId)
300
+ throw new Error("Switch to the tenant before inviting a member.");
301
+ return props.client.reducer(control.invitations.create, { email, role: options?.role ?? "member", permissions: (options?.permissions ?? {}), teamIds: options?.teamIds ?? [], allowedAuthProviders: options?.allowedAuthProviders ?? [], payload: options?.payload ?? {} });
302
+ }, [fetchAccessToken, props.client]);
303
+ const acceptInvitation = useCallback(async (token) => {
304
+ const accessToken = await fetchAccessToken({ forceRefreshToken: false });
305
+ if (!accessToken)
306
+ throw new Error("Sign in before accepting an invitation.");
307
+ return props.client.reducer(control.invitations.accept, { token });
308
+ }, [fetchAccessToken, props.client]);
309
309
  const revokeInvitation = useCallback(async (tenantId, email) => {
310
310
  const token = await fetchAccessToken({ forceRefreshToken: false });
311
311
  if (!token)
312
312
  throw new Error("Sign in before revoking an invitation.");
313
- const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/invitations/${encodeURIComponent(email)}`, {
314
- method: "DELETE", headers: { authorization: `Bearer ${token}` },
315
- });
316
- const payload = await response.json().catch(() => ({}));
317
- if (!response.ok)
318
- throw new Error(payload.error ?? "Could not revoke the invitation.");
319
- }, [fetchAccessToken, runtimeUrl]);
313
+ if (tenantId !== sessionRef.current?.activeTenantId)
314
+ throw new Error("Switch to the tenant before revoking an invitation.");
315
+ await props.client.reducer(control.invitations.revoke, { id: "", email });
316
+ }, [fetchAccessToken, props.client]);
320
317
  const activeTenant = session?.tenants.find((tenant) => tenant.id === session.activeTenantId) ?? null;
321
318
  const authValue = useMemo(() => ({
322
319
  isLoading,
323
320
  isAuthenticated: Boolean(session && session.refreshExpiresAt > Date.now()),
324
321
  fetchAccessToken,
325
- user: session?.user ?? null,
322
+ account: session?.account ?? null,
326
323
  tenants: session?.tenants ?? [],
327
324
  activeTenant,
328
325
  error,
329
326
  signIn,
327
+ signInWithProvider,
328
+ signInWithPassword,
330
329
  signOut,
331
330
  setActiveTenant,
332
331
  refreshMemberships,
333
332
  createTenant,
334
333
  inviteMember,
334
+ acceptInvitation,
335
335
  revokeInvitation,
336
- removeMember,
337
- }), [activeTenant, createTenant, error, fetchAccessToken, inviteMember, isLoading, refreshMemberships, removeMember, revokeInvitation, session, setActiveTenant, signIn, signOut]);
336
+ }), [acceptInvitation, activeTenant, createTenant, error, fetchAccessToken, inviteMember, isLoading, refreshMemberships, revokeInvitation, session, setActiveTenant, signIn, signInWithPassword, signInWithProvider, signOut]);
338
337
  return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? null : props.children }) }) }));
339
338
  }
340
339
  export function useGonvexAuth() {
@@ -343,6 +342,18 @@ export function useGonvexAuth() {
343
342
  throw new Error("GonvexAuthProvider is required");
344
343
  return value;
345
344
  }
345
+ /** Subscribed profile for the active tenant, reconciled by GonvexAuthProvider. */
346
+ export function useCurrentTenantProfile() {
347
+ return useGonvexAuth().activeTenant;
348
+ }
349
+ /** Live tenant-admin invitation list; reducer changes reconcile automatically. */
350
+ export function useInvitationList() {
351
+ return useControlQuery(control.invitations.list, {});
352
+ }
353
+ /** Read the auth state installed by either auth provider. */
354
+ export function useGonvexAuthState() {
355
+ return useContext(GonvexAuthContext);
356
+ }
346
357
  export function GonvexGoogleAuthButton(props) {
347
358
  const { signOutLabel = "Sign out", children, disabled, onClick, ...buttonProps } = props;
348
359
  const auth = useGonvexAuth();
@@ -386,10 +397,9 @@ async function bootstrapGonvexAuth(options) {
386
397
  return latest;
387
398
  if (latest.refreshExpiresAt <= Date.now())
388
399
  throw new GonvexAuthRequestError("Your session expired. Please sign in again.", 401);
389
- const next = await requestGonvexAuthToken(options.runtimeUrl, {
390
- grantType: "refresh_token", project: options.projectId,
391
- refreshToken: latest.refreshToken, tenant: latest.activeTenantId,
392
- });
400
+ options.client.setAuth({ project: options.projectId, token: latest.accessToken });
401
+ const grant = await options.client.action(control.auth.refreshSession, { refreshToken: latest.refreshToken });
402
+ const next = sessionFromNativeGrant(grant, latest);
393
403
  safeLocalStorageSet(options.storageKey, JSON.stringify(next));
394
404
  return next;
395
405
  });
@@ -401,26 +411,34 @@ async function bootstrapGonvexAuth(options) {
401
411
  }
402
412
  }
403
413
  const pkce = readPKCE(options.pkceStorageKey);
404
- // Prefer surfacing the runtime/Google error when PKCE is missing (e.g. after
405
- // a StrictMode remount or a second tab), rather than always saying "verified".
414
+ const provider = pkce?.provider ?? "google";
415
+ const providerLabel = provider[0].toUpperCase() + provider.slice(1);
416
+ // Surface the runtime error even when another tab already consumed PKCE.
406
417
  if (callbackError) {
407
418
  const messages = {
408
- access_denied: "Google sign-in was cancelled.",
409
- invitation_required: "This app is invite-only. Ask an administrator to invite your verified Google email.",
419
+ access_denied: `${providerLabel} sign-in was cancelled.`,
420
+ invitation_required: `This app is invite-only. Ask an administrator to invite your verified ${providerLabel} email.`,
410
421
  verified_google_email_required: "Google must provide a verified email address for this app.",
422
+ verified_microsoft_email_required: "Microsoft must provide a verified email address for this app.",
411
423
  membership_setup_failed: "Your account was verified, but its workspace could not be prepared. Please try again.",
412
424
  google_exchange_failed: "Google rejected the sign-in code exchange. Check GONVEX_GOOGLE_CLIENT_ID/SECRET and the broker callback URI.",
425
+ microsoft_exchange_failed: "Microsoft rejected the sign-in code exchange. Check the project's Microsoft realm configuration.",
426
+ apple_exchange_failed: "Apple rejected the sign-in code exchange. Check the project's Apple realm configuration.",
413
427
  invalid_google_identity: "Google identity verification failed. Please try again.",
414
- account_creation_failed: "Your Google account could not be linked. Please try again.",
428
+ invalid_microsoft_identity: "Microsoft identity verification failed. Please try again.",
429
+ invalid_apple_identity: "Apple identity verification failed. Please try again.",
430
+ microsoft_not_configured: "Microsoft sign-in is not configured for this project.",
431
+ apple_not_configured: "Apple sign-in is not configured for this project.",
432
+ account_creation_failed: `Your ${providerLabel} account could not be linked. Please try again.`,
415
433
  code_creation_failed: "Gonvex could not finish creating a sign-in code. Please try again.",
416
434
  };
417
435
  safeSessionStorageRemove(options.pkceStorageKey);
418
436
  clearAuthCallbackParams(url, pkce?.returnTo);
419
- throw new Error(messages[callbackError] ?? `Google sign-in failed (${callbackError}). Please try again.`);
437
+ throw new Error(messages[callbackError] ?? `${providerLabel} sign-in failed (${callbackError}). Please try again.`);
420
438
  }
421
439
  if (!pkce || !returnedState || returnedState !== pkce.state || Date.now() - pkce.createdAt > 10 * 60 * 1000) {
422
440
  clearAuthCallbackParams(url, pkce?.returnTo);
423
- throw new Error("The Google sign-in response could not be verified. Please try again.");
441
+ throw new Error(`The ${providerLabel} sign-in response could not be verified. Please try again.`);
424
442
  }
425
443
  // Consume PKCE only after validation so a concurrent remount still sees it.
426
444
  safeSessionStorageRemove(options.pkceStorageKey);
@@ -434,6 +452,16 @@ async function bootstrapGonvexAuth(options) {
434
452
  safeLocalStorageSet(options.storageKey, JSON.stringify(session));
435
453
  return session;
436
454
  }
455
+ function sessionFromNativeGrant(value, previous) {
456
+ if (!value || typeof value !== "object" || Array.isArray(value) || !isGonvexAuthSession(value)) {
457
+ throw new GonvexAuthRequestError("Gonvex returned an invalid native session.", 502);
458
+ }
459
+ const session = value;
460
+ const activeTenantId = previous && session.tenants.some((tenant) => tenant.id === previous.activeTenantId)
461
+ ? previous.activeTenantId
462
+ : session.activeTenantId;
463
+ return { ...session, activeTenantId };
464
+ }
437
465
  async function requestGonvexAuthToken(runtimeUrl, body) {
438
466
  const controller = new AbortController();
439
467
  const timeout = window.setTimeout(() => controller.abort(), 15_000);
@@ -461,7 +489,7 @@ function isFatalRefreshError(cause) {
461
489
  }
462
490
  function isGonvexAuthSession(value) {
463
491
  return Boolean(value.accessToken && value.expiresAt && value.refreshToken && value.refreshExpiresAt
464
- && value.user?.id && Array.isArray(value.tenants));
492
+ && value.account?.id && Array.isArray(value.tenants));
465
493
  }
466
494
  async function withBrowserAuthLock(name, action) {
467
495
  const locks = typeof navigator === "undefined"
@@ -534,7 +562,7 @@ function readAuthSession(key) {
534
562
  return null;
535
563
  try {
536
564
  const parsed = JSON.parse(localStorage.getItem(key) ?? "null");
537
- if (!parsed?.accessToken || !parsed.refreshToken || !parsed.user?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
565
+ if (!parsed?.accessToken || !parsed.refreshToken || !parsed.account?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
538
566
  safeLocalStorageRemove(key);
539
567
  return null;
540
568
  }
@@ -575,11 +603,7 @@ function bytesToBase64Url(bytes) {
575
603
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
576
604
  }
577
605
  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
- */
606
+ /** One-shot Query hook with explicit loading/error/timeout status and retry. */
583
607
  export function useQueryResult(ref, args = {}, options = {}) {
584
608
  const client = useGonvexClient();
585
609
  const path = ref.path;
@@ -588,6 +612,7 @@ export function useQueryResult(ref, args = {}, options = {}) {
588
612
  const argsKey = JSON.stringify(args);
589
613
  const keepPreviousData = options.keepPreviousData !== false;
590
614
  const timeoutMs = options.timeoutMs ?? DEFAULT_LIVE_QUERY_SLOW_MS;
615
+ const [requestGeneration, setRequestGeneration] = useState(0);
591
616
  const [state, setState] = useState({
592
617
  data: undefined,
593
618
  status: args === "skip" ? "skip" : "loading",
@@ -619,147 +644,183 @@ export function useQueryResult(ref, args = {}, options = {}) {
619
644
  setState({ data: undefined, status: "skip", error: null, isStale: false });
620
645
  return;
621
646
  }
622
- setState({ data: undefined, status: "loading", error: null, isStale: false });
647
+ setState((previous) => ({
648
+ data: keepPreviousData ? previous.data : undefined,
649
+ status: "loading",
650
+ error: null,
651
+ isStale: keepPreviousData && previous.data !== undefined,
652
+ }));
623
653
  startSlowTimer();
624
- const unsubscribeScope = client.onSessionScopeChange(() => {
625
- setState({ data: undefined, status: "loading", error: null, isStale: false });
626
- startSlowTimer();
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
- });
654
+ let active = true;
655
+ void client.query(ref, args).then((data) => {
656
+ if (!active)
657
657
  return;
658
- }
659
- // The client resubscribes live queries itself on reconnect; report
660
- // loading until the fresh result lands.
661
- setState((previous) => {
662
- if (previous.status !== "disconnected")
663
- return previous;
664
- startSlowTimer();
665
- return { ...previous, status: "loading" };
666
- });
667
- };
668
- const unsubscribeConnection = typeof client.subscribeToConnectionState === "function"
669
- ? (() => {
670
- if (typeof client.connectionState === "function") {
671
- applyConnection(client.connectionState());
672
- }
673
- return client.subscribeToConnectionState(applyConnection);
674
- })()
675
- : undefined;
658
+ clearSlowTimer();
659
+ setState({ data, status: "success", error: null, isStale: false });
660
+ }, (failure) => {
661
+ if (!active)
662
+ return;
663
+ clearSlowTimer();
664
+ const error = failure instanceof Error
665
+ ? failure
666
+ : new GonvexClientError(String(failure), { code: "server", path, operation: "query" });
667
+ setState((previous) => ({
668
+ data: keepPreviousData ? previous.data : undefined,
669
+ status: "error",
670
+ error,
671
+ isStale: keepPreviousData && previous.data !== undefined,
672
+ }));
673
+ });
676
674
  return () => {
675
+ active = false;
677
676
  clearSlowTimer();
678
- unsubscribeScope();
679
- unsubscribeQuery();
680
- unsubscribeConnection?.();
681
677
  };
682
678
  // eslint-disable-next-line react-hooks/exhaustive-deps
683
- }, [client, kind, path, optimisticKey, argsKey, keepPreviousData, startSlowTimer, clearSlowTimer]);
679
+ }, [client, kind, path, optimisticKey, argsKey, keepPreviousData, requestGeneration, startSlowTimer, clearSlowTimer]);
684
680
  const retry = useCallback(() => {
685
681
  if (args === "skip")
686
682
  return;
687
- setState((previous) => ({
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
- }
683
+ setRequestGeneration((generation) => generation + 1);
697
684
  // eslint-disable-next-line react-hooks/exhaustive-deps
698
- }, [client, kind, path, optimisticKey, argsKey, startSlowTimer]);
685
+ }, [argsKey]);
699
686
  return {
700
687
  data: state.data,
701
688
  status: state.status,
702
689
  error: state.error,
703
690
  isLoading: state.status === "loading",
704
- isError: state.status === "error" || state.status === "timeout" || state.status === "disconnected",
691
+ isError: state.status === "error" || state.status === "timeout",
705
692
  isSuccess: state.status === "success",
706
693
  isStale: state.isStale,
707
694
  retry,
708
695
  };
709
696
  }
710
- export function useQuery(ref, args = {}) {
697
+ export function useLiveQuery(ref, args = {}) {
711
698
  const client = useGonvexClient();
712
- const [result, setResult] = useState();
713
- const [error, setError] = useState(null);
699
+ if (ref.delivery !== "live" || !ref.live?.plan) {
700
+ throw new Error(`useLiveQuery requires a structured Live Query reference: ${ref.path}`);
701
+ }
714
702
  const path = ref.path;
715
703
  const kind = ref.kind;
716
704
  const optimisticKey = JSON.stringify(ref.optimistic ?? null);
717
705
  const argsKey = JSON.stringify(args);
706
+ const liveKey = JSON.stringify(ref.live ?? null);
707
+ const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args),
708
+ // eslint-disable-next-line react-hooks/exhaustive-deps
709
+ [client, kind, path, optimisticKey, argsKey, liveKey]);
710
+ const liveResult = useSyncExternalStore(useCallback((notify) => liveWatch?.onUpdate(notify) ?? (() => undefined), [liveWatch]), useCallback(() => liveWatch?.localLiveQueryResult(), [liveWatch]), () => undefined);
711
+ return liveResult;
712
+ }
713
+ /** Subscribe to a host-owned Control Plane Query on the existing Gonvex connection. */
714
+ export function useControlQuery(ref, args = {}) {
715
+ const client = useGonvexClient();
716
+ const argsKey = JSON.stringify(args);
717
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchControlQuery(ref, args),
718
+ // eslint-disable-next-line react-hooks/exhaustive-deps
719
+ [client, ref.kind, ref.path, argsKey]);
720
+ const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined);
721
+ return snapshot?.result;
722
+ }
723
+ /** Read one normalized entity from the single Gonvex Local Replica. */
724
+ export function useEntity(entity, id) {
725
+ const client = useGonvexClient();
726
+ useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
727
+ return client.localReplica.entity(entity, id);
728
+ }
729
+ /** Resolve an ordered entity batch with one Local Replica subscription. */
730
+ export function useReplicaEntities(entity, ids) {
731
+ const client = useGonvexClient();
732
+ const idsKey = JSON.stringify(ids);
733
+ const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
734
+ return useMemo(() => client.replicaEntities(entity, ids), [client, entity, idsKey, version]);
735
+ }
736
+ /** Read a persisted Live Query window without opening another server subscription. */
737
+ export function useRetainedLiveQuery(signatureOrReference, args = {}) {
738
+ const client = useGonvexClient();
739
+ const argsKey = JSON.stringify(args);
740
+ const signature = typeof signatureOrReference === "string"
741
+ ? signatureOrReference
742
+ : client.replicaSignature(signatureOrReference, args);
743
+ const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
744
+ return useMemo(() => client.retainedLiveQuery(signature), [client, signature, argsKey, version]);
745
+ }
746
+ /** Structured Live Query state backed by normalized Local Replica entities. */
747
+ export function useLiveQueryState(ref, args = {}) {
748
+ const client = useGonvexClient();
749
+ const argsKey = JSON.stringify(args);
750
+ const signature = args === "skip" ? "" : client.replicaSignature(ref, args);
751
+ useEffect(() => {
752
+ if (args === "skip")
753
+ return;
754
+ return client.subscribeLiveQuery(ref, args, () => undefined);
755
+ // eslint-disable-next-line react-hooks/exhaustive-deps
756
+ }, [client, ref.kind, ref.path, argsKey]);
757
+ useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
758
+ if (args !== "skip" && client.localReplica.freshness() === "offline") {
759
+ const offline = client.offlineLiveQuery(ref, args);
760
+ return {
761
+ rows: offline.rows,
762
+ ids: offline.rows.map((row) => String(row.id ?? row._id ?? "")).filter(Boolean),
763
+ ...(offline.total === undefined ? {} : { total: offline.total }),
764
+ ...(offline.offset === undefined ? {} : { offset: offline.offset }),
765
+ ...(offline.limit === undefined ? {} : { limit: offline.limit }),
766
+ source: "cache",
767
+ completeness: offline.completeness,
768
+ freshness: "offline",
769
+ supported: offline.supported,
770
+ ...(offline.unsupportedOperator ? { unsupportedOperator: offline.unsupportedOperator } : {}),
771
+ };
772
+ }
773
+ return signature
774
+ ? client.localReplica.liveQuery(signature)
775
+ : { rows: [], ids: [], source: "cache", completeness: "partial", freshness: client.localReplica.freshness() };
776
+ }
777
+ /** Execute a read-only Query once. Queries never subscribe or rerun. */
778
+ export function useQuery(ref, args = {}) {
779
+ const client = useGonvexClient();
780
+ const [result, setResult] = useState();
781
+ const [error, setError] = useState(null);
782
+ const argsKey = JSON.stringify(args);
718
783
  useEffect(() => {
784
+ let active = true;
719
785
  if (args === "skip") {
720
786
  setResult(undefined);
721
787
  setError(null);
722
- return;
788
+ return () => { active = false; };
723
789
  }
724
790
  setResult(undefined);
725
791
  setError(null);
726
- const unsubscribeScope = client.onSessionScopeChange(() => {
727
- setResult(undefined);
728
- setError(null);
729
- });
730
- const unsubscribeQuery = client.subscribeQuery(ref, args, (message) => {
731
- if (message.type === "query.result") {
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.
792
+ void client.query(ref, args).then((value) => { if (active)
793
+ setResult(value); }, (failure) => { if (active)
794
+ setError(failure instanceof Error ? failure : new Error(String(failure))); });
795
+ return () => { active = false; };
796
+ // eslint-disable-next-line react-hooks/exhaustive-deps
797
+ }, [client, ref.kind, ref.path, argsKey]);
747
798
  if (error)
748
799
  throw error;
749
800
  return result;
750
801
  }
751
- export function useSync(ref, args = {}) {
802
+ export function useReplicaCollection(ref, args = {}) {
752
803
  const client = useGonvexClient();
753
804
  const path = ref.path;
754
805
  const kind = ref.kind;
755
806
  const optimisticKey = JSON.stringify(ref.optimistic ?? null);
756
807
  const argsKey = JSON.stringify(args);
757
- const watch = useMemo(() => args === "skip" ? undefined : client.watchSync(ref, args),
808
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
758
809
  // eslint-disable-next-line react-hooks/exhaustive-deps
759
810
  [client, kind, path, optimisticKey, argsKey]);
760
- return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localSyncResult(), [watch]), () => undefined);
811
+ return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaResult(), [watch]), () => undefined);
812
+ }
813
+ /** Replica rows plus authoritative completeness, truncation, and freshness metadata. */
814
+ export function useReplicaCollectionState(ref, args = {}) {
815
+ const client = useGonvexClient();
816
+ const path = ref.path;
817
+ const argsKey = JSON.stringify(args);
818
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
819
+ // eslint-disable-next-line react-hooks/exhaustive-deps
820
+ [client, ref.kind, path, argsKey]);
821
+ return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaState(), [watch]), () => undefined);
761
822
  }
762
- export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
823
+ export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
763
824
  const client = useGonvexClient();
764
825
  const path = ref.path;
765
826
  const kind = ref.kind;
@@ -769,7 +830,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
769
830
  const equalityRef = useRef(isEqual);
770
831
  selectorRef.current = selector;
771
832
  equalityRef.current = isEqual;
772
- const watch = useMemo(() => args === "skip" ? undefined : client.watchSync(ref, args),
833
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
773
834
  // eslint-disable-next-line react-hooks/exhaustive-deps
774
835
  [client, kind, path, optimisticKey, argsKey]);
775
836
  const selectedRef = useRef({
@@ -780,7 +841,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
780
841
  selectedRef.current = { initialized: false, value: undefined };
781
842
  }, [watch]);
782
843
  const getSnapshot = useCallback(() => {
783
- const rows = watch?.localSyncResult();
844
+ const rows = watch?.localReplicaResult();
784
845
  const next = rows === undefined ? undefined : selectorRef.current(rows);
785
846
  if (!selectedRef.current.initialized
786
847
  || next === undefined
@@ -795,7 +856,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
795
856
  return () => undefined;
796
857
  return watch.onUpdate(() => {
797
858
  const previous = selectedRef.current.value;
798
- const rows = watch.localSyncResult();
859
+ const rows = watch.localReplicaResult();
799
860
  const next = rows === undefined ? undefined : selectorRef.current(rows);
800
861
  if (selectedRef.current.initialized
801
862
  && previous !== undefined
@@ -808,31 +869,25 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
808
869
  }, [watch]);
809
870
  return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
810
871
  }
811
- export function useMutation(ref, options = {}) {
872
+ export function useReducer(ref, options = {}) {
812
873
  const client = useGonvexClient();
813
- return (args = {}) => client.mutation(ref, args, options);
874
+ return (args = {}) => client.reducer(ref, args, options);
814
875
  }
815
876
  export function useAction(ref, options = {}) {
816
877
  const client = useGonvexClient();
817
878
  return (args = {}) => client.action(ref, args, options);
818
879
  }
819
- export function useConvex() {
820
- return useGonvexClient();
821
- }
822
- export function useConvexAuth() {
823
- return useContext(GonvexAuthContext);
824
- }
825
880
  const FALLBACK_CONNECTION_STATE = {
826
881
  isWebSocketConnected: false,
827
882
  hasEverConnected: false,
828
883
  connectionCount: 0,
829
884
  connectionRetries: 0,
830
885
  hasInflightRequests: false,
831
- inflightMutations: 0,
886
+ inflightReducers: 0,
832
887
  inflightActions: 0,
833
888
  inflightOneShotQueries: 0,
834
889
  };
835
- export function useConvexConnectionState() {
890
+ export function useGonvexConnectionState() {
836
891
  const client = useGonvexClient();
837
892
  const [state, setState] = useState(() => (typeof client.connectionState === "function" ? client.connectionState() : FALLBACK_CONNECTION_STATE));
838
893
  useEffect(() => {
@@ -843,18 +898,7 @@ export function useConvexConnectionState() {
843
898
  }, [client]);
844
899
  return state;
845
900
  }
846
- export function usePaginatedQuery(ref, args = {}, options = {}) {
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() {
901
+ export function useGonvexClient() {
858
902
  const client = useContext(GonvexContext);
859
903
  if (!client)
860
904
  throw new Error("GonvexProvider is required");