@gonvex/react 0.5.2-staging.2 → 0.5.2-staging.20
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 +14 -7
- package/dist/index.d.ts +54 -4
- package/dist/index.js +550 -100
- package/dist/index.js.map +1 -1
- package/dist/paint-external-store.d.ts +4 -0
- package/dist/paint-external-store.js +69 -0
- package/dist/paint-external-store.js.map +1 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
-
import {
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore as useReactSyncExternalStore } from "react";
|
|
3
|
+
import { usePaintExternalStore as useSyncExternalStore } from "./paint-external-store.js";
|
|
4
|
+
import { GonvexClientError, control, entityStatusFromIntents } from "@gonvex/client";
|
|
4
5
|
export { GonvexClientError } from "@gonvex/client";
|
|
5
6
|
export { createFirebaseAuthAdapter } from "@gonvex/client";
|
|
6
7
|
const GonvexContext = createContext(null);
|
|
@@ -94,8 +95,13 @@ export function GonvexProviderWithAuth(props) {
|
|
|
94
95
|
// code+PKCE exchange runs once. Without this, the first effect's finally
|
|
95
96
|
// clears sessionStorage PKCE before the remount can finish verification.
|
|
96
97
|
const authBootstrapPromises = new Map();
|
|
98
|
+
// Creating a tenant provisions its database and runs every tenant migration,
|
|
99
|
+
// which routinely takes longer than the default reducer timeout. Timing out
|
|
100
|
+
// made onboarding report a failure for a tenant that was still being created.
|
|
101
|
+
const TENANT_CREATE_TIMEOUT_MS = 120_000;
|
|
97
102
|
export function GonvexAuthProvider(props) {
|
|
98
103
|
const runtimeUrl = props.runtimeUrl.replace(/\/+$/, "");
|
|
104
|
+
const hasExplicitInitialTenant = props.initialTenantId !== undefined;
|
|
99
105
|
const initialTenantId = props.initialTenantId?.trim() || undefined;
|
|
100
106
|
const callbackPath = normalizeCallbackPath(props.callbackPath ?? "/");
|
|
101
107
|
const storageKey = `gonvex-auth:${encodeURIComponent(runtimeUrl)}:${props.projectId}`;
|
|
@@ -103,23 +109,55 @@ export function GonvexAuthProvider(props) {
|
|
|
103
109
|
const initialAuthRef = useRef(null);
|
|
104
110
|
if (!initialAuthRef.current) {
|
|
105
111
|
const persisted = readAuthSession(storageKey);
|
|
112
|
+
const warmSession = props.externalAuth
|
|
113
|
+
? reusableExternalAuthSession(persisted, initialTenantId, props.externalAuth.provider, hasExplicitInitialTenant)
|
|
114
|
+
: null;
|
|
115
|
+
// A reload keeps developer mode only while this tab's stored grant
|
|
116
|
+
// belongs to the same account and project and has not expired.
|
|
117
|
+
const candidate = warmSession ?? persisted;
|
|
118
|
+
const storedDeveloper = candidate
|
|
119
|
+
? readDeveloperGrant(storageKey, props.projectId, candidate.account.id)
|
|
120
|
+
: null;
|
|
121
|
+
clearDeveloperGrants(storageKey, storedDeveloper?.accountId);
|
|
106
122
|
initialAuthRef.current = {
|
|
107
123
|
session: persisted,
|
|
108
|
-
warmSession
|
|
109
|
-
|
|
124
|
+
warmSession,
|
|
125
|
+
developer: storedDeveloper
|
|
126
|
+
? {
|
|
127
|
+
active: true,
|
|
128
|
+
tenantId: storedDeveloper.tenantId,
|
|
129
|
+
grantId: storedDeveloper.grantId,
|
|
130
|
+
expiresAt: storedDeveloper.expiresAt,
|
|
131
|
+
accountId: storedDeveloper.accountId,
|
|
132
|
+
originalTenantId: storedDeveloper.originalTenantId,
|
|
133
|
+
clientInstalled: false,
|
|
134
|
+
restoring: true,
|
|
135
|
+
}
|
|
110
136
|
: null,
|
|
137
|
+
developerToken: storedDeveloper?.token ?? null,
|
|
111
138
|
};
|
|
112
139
|
}
|
|
113
140
|
const initialAuth = initialAuthRef.current;
|
|
114
|
-
|
|
141
|
+
// The session exposed to React must be the same scoped session installed on
|
|
142
|
+
// the client. On an explicit landlord origin, warmSession intentionally
|
|
143
|
+
// clears a persisted tenant selection. Keeping the unscoped persisted value
|
|
144
|
+
// here makes the tenant-directory effect see a different auth scope and skip
|
|
145
|
+
// its authoritative Control Query, leaving deleted tenants in the UI.
|
|
146
|
+
const initialSession = initialAuth.warmSession ?? initialAuth.session;
|
|
147
|
+
const [session, setSession] = useState(initialSession);
|
|
115
148
|
const [isLoading, setIsLoading] = useState(!initialAuth.warmSession);
|
|
116
149
|
const [sessionState, setSessionState] = useState(initialAuth.warmSession ? "reconnecting" : "loading");
|
|
117
150
|
const [error, setError] = useState(null);
|
|
118
151
|
const [refreshRetryAt, setRefreshRetryAt] = useState(0);
|
|
119
|
-
const [
|
|
152
|
+
const [canonicalRefreshRequested, setCanonicalRefreshRequested] = useState(false);
|
|
153
|
+
const [developerMode, setDeveloperMode] = useState(() => initialAuth.developer ? publicDeveloperMode(initialAuth.developer) : { active: false });
|
|
120
154
|
const sessionRef = useRef(session);
|
|
121
155
|
const refreshRef = useRef(null);
|
|
122
|
-
const developerModeRef = useRef(
|
|
156
|
+
const developerModeRef = useRef(initialAuth.developer);
|
|
157
|
+
// The newest grant credential lives beside, never inside, the developer
|
|
158
|
+
// state so it cannot leak through the public auth value.
|
|
159
|
+
const developerTokenRef = useRef(initialAuth.developerToken);
|
|
160
|
+
const pendingDeveloperTokenRef = useRef(null);
|
|
123
161
|
// A persisted session is only a candidate until bootstrap/external auth has
|
|
124
162
|
// installed its complete project + tenant + account scope on the client.
|
|
125
163
|
// Never let the token-refresh effect authenticate an account-only socket in
|
|
@@ -130,28 +168,51 @@ export function GonvexAuthProvider(props) {
|
|
|
130
168
|
const installedWarmSessionRef = useRef(false);
|
|
131
169
|
if (initialAuth.warmSession && !installedWarmSessionRef.current) {
|
|
132
170
|
installedWarmSessionRef.current = true;
|
|
133
|
-
|
|
171
|
+
const developer = developerModeRef.current;
|
|
172
|
+
const developerToken = developerTokenRef.current;
|
|
173
|
+
if (developer && developerToken) {
|
|
174
|
+
installDeveloperClientAuth(props.client, props.projectId, developer, developerToken);
|
|
175
|
+
developer.clientInstalled = true;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
installClientSession(props.client, props.projectId, initialAuth.warmSession);
|
|
179
|
+
}
|
|
134
180
|
}
|
|
135
181
|
const installSession = useCallback((next, persist = true, installClientAuth = true) => {
|
|
136
182
|
sessionRef.current = next;
|
|
183
|
+
const developer = developerModeRef.current;
|
|
184
|
+
if (developer && next?.account.id !== developer.accountId) {
|
|
185
|
+
// Signing out or switching accounts ends developer mode. The grant was
|
|
186
|
+
// issued to the previous account and must not survive into this one.
|
|
187
|
+
developerModeRef.current = null;
|
|
188
|
+
developerTokenRef.current = null;
|
|
189
|
+
setDeveloperMode({ active: false });
|
|
190
|
+
clearDeveloperGrants(storageKey);
|
|
191
|
+
}
|
|
192
|
+
const activeDeveloper = developerModeRef.current;
|
|
137
193
|
if (next) {
|
|
138
194
|
if (persist)
|
|
139
195
|
safeLocalStorageSet(storageKey, JSON.stringify(next));
|
|
140
|
-
if (!
|
|
141
|
-
|
|
196
|
+
if (!activeDeveloper) {
|
|
197
|
+
if (installClientAuth)
|
|
198
|
+
installClientSession(props.client, props.projectId, next);
|
|
199
|
+
installedClientScopeRef.current = authSessionScope(props.projectId, next);
|
|
142
200
|
}
|
|
143
|
-
if (!
|
|
201
|
+
else if (!activeDeveloper.clientInstalled && installClientAuth && developerTokenRef.current) {
|
|
202
|
+
// A grant restored after reload waits for its account session to be
|
|
203
|
+
// confirmed, then connects with the grant instead of that session.
|
|
204
|
+
installDeveloperClientAuth(props.client, props.projectId, activeDeveloper, developerTokenRef.current);
|
|
205
|
+
activeDeveloper.clientInstalled = true;
|
|
144
206
|
installedClientScopeRef.current = authSessionScope(props.projectId, next);
|
|
145
207
|
}
|
|
146
208
|
}
|
|
147
209
|
else {
|
|
148
210
|
if (persist)
|
|
149
211
|
safeLocalStorageRemove(storageKey);
|
|
150
|
-
if (
|
|
212
|
+
if (installClientAuth) {
|
|
151
213
|
props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined, identity: undefined });
|
|
152
214
|
}
|
|
153
|
-
|
|
154
|
-
installedClientScopeRef.current = null;
|
|
215
|
+
installedClientScopeRef.current = null;
|
|
155
216
|
}
|
|
156
217
|
setSession(next);
|
|
157
218
|
}, [props.client, props.projectId, storageKey]);
|
|
@@ -183,7 +244,7 @@ export function GonvexAuthProvider(props) {
|
|
|
183
244
|
|| message.targetOrigin !== window.location.origin)
|
|
184
245
|
return;
|
|
185
246
|
const candidate = message.session ?? null;
|
|
186
|
-
const reusable = reusableExternalAuthSession(candidate, initialTenantId, props.externalAuth?.provider ?? candidate?.account.provider ?? "");
|
|
247
|
+
const reusable = reusableExternalAuthSession(candidate, initialTenantId, props.externalAuth?.provider ?? candidate?.account.provider ?? "", hasExplicitInitialTenant);
|
|
187
248
|
if (!reusable)
|
|
188
249
|
return;
|
|
189
250
|
installSession(reusable);
|
|
@@ -194,25 +255,46 @@ export function GonvexAuthProvider(props) {
|
|
|
194
255
|
window.addEventListener("message", onMessage);
|
|
195
256
|
window.parent.postMessage({ type: SESSION_HANDOFF_READY, nonce }, parentOrigin);
|
|
196
257
|
return () => window.removeEventListener("message", onMessage);
|
|
197
|
-
}, [initialTenantId, installSession, props.crossOriginHandoff, props.externalAuth?.provider, props.projectId, runtimeUrl]);
|
|
258
|
+
}, [hasExplicitInitialTenant, initialTenantId, installSession, props.crossOriginHandoff, props.externalAuth?.provider, props.projectId, runtimeUrl]);
|
|
198
259
|
const restoreAccountSession = useCallback(() => {
|
|
199
260
|
const developer = developerModeRef.current;
|
|
200
261
|
if (!developer)
|
|
201
262
|
return;
|
|
202
263
|
developerModeRef.current = null;
|
|
264
|
+
developerTokenRef.current = null;
|
|
265
|
+
clearDeveloperGrants(storageKey);
|
|
203
266
|
setDeveloperMode({ active: false });
|
|
204
267
|
const current = sessionRef.current;
|
|
205
268
|
const activeTenantId = current?.tenants.some((tenant) => tenant.id === developer.originalTenantId)
|
|
206
269
|
? developer.originalTenantId
|
|
207
270
|
: current?.activeTenantId;
|
|
208
271
|
installSession(current ? { ...current, activeTenantId } : null);
|
|
209
|
-
}, [installSession]);
|
|
272
|
+
}, [installSession, storageKey]);
|
|
210
273
|
useEffect(() => props.client.onAuthError((message) => {
|
|
211
|
-
|
|
274
|
+
const developer = developerModeRef.current;
|
|
275
|
+
if (!developer)
|
|
212
276
|
return;
|
|
213
277
|
restoreAccountSession();
|
|
214
|
-
|
|
278
|
+
// A grant restored after a reload may already be gone (exited in another
|
|
279
|
+
// tab, revoked, or expired server-side). Falling back to the account
|
|
280
|
+
// session is the expected outcome there, not an error to surface.
|
|
281
|
+
setError(developer.restoring ? null : message || "Developer mode ended because its authorization is no longer valid.");
|
|
215
282
|
}), [props.client, restoreAccountSession]);
|
|
283
|
+
useEffect(() => props.client.onDeveloperCredential?.((token) => {
|
|
284
|
+
// The runtime consumed the previous credential and issued a single-use
|
|
285
|
+
// successor. Keep only the newest one so a reload can resume the grant.
|
|
286
|
+
const pending = pendingDeveloperTokenRef.current;
|
|
287
|
+
if (pending) {
|
|
288
|
+
pending.token = token;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const developer = developerModeRef.current;
|
|
292
|
+
if (!developer?.clientInstalled)
|
|
293
|
+
return;
|
|
294
|
+
developer.restoring = false;
|
|
295
|
+
developerTokenRef.current = token;
|
|
296
|
+
writeDeveloperGrant(storageKey, props.projectId, developer, token);
|
|
297
|
+
}), [props.client, props.projectId, storageKey]);
|
|
216
298
|
useEffect(() => {
|
|
217
299
|
if (!developerMode.active || !developerMode.expiresAt)
|
|
218
300
|
return;
|
|
@@ -273,8 +355,9 @@ export function GonvexAuthProvider(props) {
|
|
|
273
355
|
// Explicit signOut() clears both systems, and its localStorage removal
|
|
274
356
|
// propagates to every tab through the storage listener below.
|
|
275
357
|
const canonical = readAuthSession(storageKey) ?? sessionRef.current;
|
|
276
|
-
const reusableCanonical = reusableExternalAuthSession(canonical, initialTenantId, adapter.provider);
|
|
358
|
+
const reusableCanonical = reusableExternalAuthSession(canonical, initialTenantId, adapter.provider, hasExplicitInitialTenant);
|
|
277
359
|
if (reusableCanonical) {
|
|
360
|
+
setCanonicalRefreshRequested(false);
|
|
278
361
|
if (!sameInstalledAuthSession(sessionRef.current, reusableCanonical)) {
|
|
279
362
|
installSession(reusableCanonical, false);
|
|
280
363
|
}
|
|
@@ -282,15 +365,18 @@ export function GonvexAuthProvider(props) {
|
|
|
282
365
|
setIsLoading(false);
|
|
283
366
|
}
|
|
284
367
|
else if (canonical) {
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
368
|
+
// Firebase persistence is origin-scoped, while the canonical Gonvex
|
|
369
|
+
// session is deliberately handed across tenant subdomains. Refresh
|
|
370
|
+
// that canonical session directly instead of waiting forever for a
|
|
371
|
+
// Firebase user that may never exist on this sibling origin.
|
|
288
372
|
sessionRef.current = canonical;
|
|
289
373
|
setSession(canonical);
|
|
290
374
|
setSessionState("loading");
|
|
291
375
|
setIsLoading(true);
|
|
376
|
+
setCanonicalRefreshRequested(true);
|
|
292
377
|
}
|
|
293
378
|
else {
|
|
379
|
+
setCanonicalRefreshRequested(false);
|
|
294
380
|
installSession(null, false);
|
|
295
381
|
setSessionState("signedOut");
|
|
296
382
|
setIsLoading(false);
|
|
@@ -298,12 +384,13 @@ export function GonvexAuthProvider(props) {
|
|
|
298
384
|
setError(null);
|
|
299
385
|
return;
|
|
300
386
|
}
|
|
387
|
+
setCanonicalRefreshRequested(false);
|
|
301
388
|
// A valid canonical Gonvex session remains usable while Firebase rotates
|
|
302
389
|
// its identity token. Keep the application mounted and perform that
|
|
303
390
|
// exchange in the background; unmounting the entire tree here destroys
|
|
304
391
|
// open dialogs and other local UI state in every existing tab.
|
|
305
392
|
const canonicalSession = readAuthSession(storageKey) ?? sessionRef.current;
|
|
306
|
-
const reusableCanonicalSession = reusableExternalAuthSession(canonicalSession, initialTenantId, adapter.provider);
|
|
393
|
+
const reusableCanonicalSession = reusableExternalAuthSession(canonicalSession, initialTenantId, adapter.provider, hasExplicitInitialTenant);
|
|
307
394
|
if (reusableCanonicalSession) {
|
|
308
395
|
// A reload already has a valid canonical Gonvex session. Install its
|
|
309
396
|
// complete tenant scope before releasing application children; the
|
|
@@ -326,7 +413,8 @@ export function GonvexAuthProvider(props) {
|
|
|
326
413
|
// is waiting for the cross-tab exchange lock. Read the shared winner
|
|
327
414
|
// inside the lock instead of replaying this tab's stale in-memory
|
|
328
415
|
// refresh token.
|
|
329
|
-
const
|
|
416
|
+
const storedCurrent = readAuthSession(storageKey) ?? sessionRef.current;
|
|
417
|
+
const current = scopeSessionForInitialTenant(storedCurrent, initialTenantId, hasExplicitInitialTenant) ?? storedCurrent;
|
|
330
418
|
attemptedRefreshToken = current?.refreshToken ?? "";
|
|
331
419
|
if (!current) {
|
|
332
420
|
props.client.setAuth({
|
|
@@ -334,7 +422,9 @@ export function GonvexAuthProvider(props) {
|
|
|
334
422
|
identity: { sub: identity.uid, iss: identity.issuer ?? adapter.provider },
|
|
335
423
|
});
|
|
336
424
|
}
|
|
337
|
-
const tenantId =
|
|
425
|
+
const tenantId = hasExplicitInitialTenant
|
|
426
|
+
? initialTenantId
|
|
427
|
+
: current?.activeTenantId;
|
|
338
428
|
const grant = await props.client.action(control.auth.exchangeExternalToken, {
|
|
339
429
|
provider: adapter.provider,
|
|
340
430
|
token,
|
|
@@ -345,7 +435,13 @@ export function GonvexAuthProvider(props) {
|
|
|
345
435
|
}).then((next) => {
|
|
346
436
|
if (cancelled || currentGeneration !== generation)
|
|
347
437
|
return;
|
|
348
|
-
|
|
438
|
+
// A user can select a different tenant while the provider exchange is
|
|
439
|
+
// still in flight. The returned access token is account-wide, but its
|
|
440
|
+
// activeTenantId reflects the scope captured when the exchange began.
|
|
441
|
+
// Preserve the latest locally accepted tenant selection so a stale
|
|
442
|
+
// background exchange cannot bounce a cross-origin handoff back to the
|
|
443
|
+
// previously active tenant.
|
|
444
|
+
installSession(preserveActiveTenant(next, sessionRef.current ?? undefined));
|
|
349
445
|
setSessionState("current");
|
|
350
446
|
setError(null);
|
|
351
447
|
}).catch((cause) => {
|
|
@@ -384,7 +480,7 @@ export function GonvexAuthProvider(props) {
|
|
|
384
480
|
generation += 1;
|
|
385
481
|
unsubscribe();
|
|
386
482
|
};
|
|
387
|
-
}, [initialTenantId, installSession, props.client, props.externalAuth, props.projectId, storageKey]);
|
|
483
|
+
}, [hasExplicitInitialTenant, initialTenantId, installSession, props.client, props.externalAuth, props.projectId, storageKey]);
|
|
388
484
|
const refreshSession = useCallback(async (force = false) => {
|
|
389
485
|
if (refreshRef.current)
|
|
390
486
|
return refreshRef.current;
|
|
@@ -393,35 +489,36 @@ export function GonvexAuthProvider(props) {
|
|
|
393
489
|
? `${storageKey}:external-session`
|
|
394
490
|
: `${storageKey}:refresh`;
|
|
395
491
|
const request = withBrowserAuthLock(lockName, async () => {
|
|
396
|
-
const
|
|
492
|
+
const storedCurrent = readAuthSession(storageKey) ?? sessionRef.current;
|
|
493
|
+
const current = scopeSessionForInitialTenant(storedCurrent, initialTenantId, hasExplicitInitialTenant) ?? storedCurrent;
|
|
397
494
|
if (!current)
|
|
398
495
|
return null;
|
|
399
496
|
if (!force && current.expiresAt > Date.now() + 60_000)
|
|
400
497
|
return current;
|
|
498
|
+
if (current.refreshExpiresAt <= Date.now())
|
|
499
|
+
return null;
|
|
401
500
|
if (props.externalAuth) {
|
|
501
|
+
if (current.account.provider !== props.externalAuth.provider)
|
|
502
|
+
return null;
|
|
402
503
|
attemptedRefreshToken = current.refreshToken;
|
|
403
504
|
const token = await props.externalAuth.getIdToken(force);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
});
|
|
419
|
-
const next = sessionFromNativeGrant(grant, current);
|
|
505
|
+
const grant = token
|
|
506
|
+
? await props.client.action(control.auth.exchangeExternalToken, {
|
|
507
|
+
provider: props.externalAuth.provider,
|
|
508
|
+
token,
|
|
509
|
+
...(current.activeTenantId ? { tenantId: current.activeTenantId } : {}),
|
|
510
|
+
previousRefreshToken: current.refreshToken,
|
|
511
|
+
})
|
|
512
|
+
: await props.client.action(control.auth.refreshSession, {
|
|
513
|
+
refreshToken: current.refreshToken,
|
|
514
|
+
});
|
|
515
|
+
const refreshed = sessionFromNativeGrant(grant, current);
|
|
516
|
+
const next = scopeSessionForInitialTenant(refreshed, initialTenantId, hasExplicitInitialTenant);
|
|
517
|
+
if (!next)
|
|
518
|
+
return null;
|
|
420
519
|
safeLocalStorageSet(storageKey, JSON.stringify(next));
|
|
421
520
|
return next;
|
|
422
521
|
}
|
|
423
|
-
if (current.refreshExpiresAt <= Date.now())
|
|
424
|
-
return null;
|
|
425
522
|
attemptedRefreshToken = current.refreshToken;
|
|
426
523
|
const grant = await props.client.action(control.auth.refreshSession, { refreshToken: current.refreshToken });
|
|
427
524
|
const next = sessionFromNativeGrant(grant, current);
|
|
@@ -435,8 +532,13 @@ export function GonvexAuthProvider(props) {
|
|
|
435
532
|
if (next) {
|
|
436
533
|
setRefreshRetryAt(0);
|
|
437
534
|
setError(null);
|
|
535
|
+
setSessionState("current");
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
setSessionState("signedOut");
|
|
438
539
|
}
|
|
439
540
|
installSession(next);
|
|
541
|
+
setIsLoading(false);
|
|
440
542
|
return next;
|
|
441
543
|
}).catch((cause) => {
|
|
442
544
|
const latest = readAuthSession(storageKey);
|
|
@@ -451,6 +553,8 @@ export function GonvexAuthProvider(props) {
|
|
|
451
553
|
if (isFatalRefreshError(cause)) {
|
|
452
554
|
installSession(null);
|
|
453
555
|
setRefreshRetryAt(0);
|
|
556
|
+
setSessionState("signedOut");
|
|
557
|
+
setIsLoading(false);
|
|
454
558
|
setError(cause instanceof Error ? cause.message : "Your session expired. Please sign in again.");
|
|
455
559
|
return null;
|
|
456
560
|
}
|
|
@@ -458,6 +562,8 @@ export function GonvexAuthProvider(props) {
|
|
|
458
562
|
// destroy a valid refresh credential. Keep it and retry shortly.
|
|
459
563
|
if (sessionRef.current)
|
|
460
564
|
setRefreshRetryAt(Date.now() + 5_000);
|
|
565
|
+
setSessionState("degraded");
|
|
566
|
+
setIsLoading(false);
|
|
461
567
|
setError("Gonvex could not refresh your session. Retrying shortly…");
|
|
462
568
|
return null;
|
|
463
569
|
}).finally(() => {
|
|
@@ -465,7 +571,19 @@ export function GonvexAuthProvider(props) {
|
|
|
465
571
|
});
|
|
466
572
|
refreshRef.current = request;
|
|
467
573
|
return request;
|
|
468
|
-
}, [installSession, props.client, props.externalAuth, storageKey]);
|
|
574
|
+
}, [hasExplicitInitialTenant, initialTenantId, installSession, props.client, props.externalAuth, storageKey]);
|
|
575
|
+
useEffect(() => {
|
|
576
|
+
if (!canonicalRefreshRequested)
|
|
577
|
+
return;
|
|
578
|
+
let cancelled = false;
|
|
579
|
+
void refreshSession().finally(() => {
|
|
580
|
+
if (!cancelled)
|
|
581
|
+
setCanonicalRefreshRequested(false);
|
|
582
|
+
});
|
|
583
|
+
return () => {
|
|
584
|
+
cancelled = true;
|
|
585
|
+
};
|
|
586
|
+
}, [canonicalRefreshRequested, refreshSession]);
|
|
469
587
|
useEffect(() => {
|
|
470
588
|
if (!session)
|
|
471
589
|
return;
|
|
@@ -557,9 +675,14 @@ export function GonvexAuthProvider(props) {
|
|
|
557
675
|
const revokeSession = current
|
|
558
676
|
? props.client.reducer(control.auth.logout, { refreshToken: current.refreshToken, all: options?.allDevices === true }).catch(() => undefined)
|
|
559
677
|
: Promise.resolve();
|
|
678
|
+
const developer = developerModeRef.current;
|
|
679
|
+
const revokeDeveloper = developer
|
|
680
|
+
? props.client.reducer(control.developer.exit, { grantId: developer.grantId }).catch(() => undefined)
|
|
681
|
+
: Promise.resolve();
|
|
682
|
+
// installSession(null) also ends developer mode and clears its stored grant.
|
|
560
683
|
installSession(null);
|
|
561
684
|
const signOutExternal = props.externalAuth?.signOut?.() ?? Promise.resolve();
|
|
562
|
-
await Promise.all([revokeSession, signOutExternal]);
|
|
685
|
+
await Promise.all([revokeSession, revokeDeveloper, signOutExternal]);
|
|
563
686
|
}, [installSession, props.client, props.externalAuth]);
|
|
564
687
|
const fetchAccessToken = useCallback(async (args) => {
|
|
565
688
|
const current = sessionRef.current;
|
|
@@ -626,7 +749,7 @@ export function GonvexAuthProvider(props) {
|
|
|
626
749
|
const tenant = await props.client.reducer(control.tenants.create, {
|
|
627
750
|
name,
|
|
628
751
|
...(options?.domain ? { domain: options.domain } : {}),
|
|
629
|
-
});
|
|
752
|
+
}, { timeoutMs: TENANT_CREATE_TIMEOUT_MS });
|
|
630
753
|
const current = sessionRef.current;
|
|
631
754
|
installSession({ ...current, tenants: [...current.tenants.filter((item) => item.id !== tenant.id), tenant], activeTenantId: tenant.id });
|
|
632
755
|
return tenant;
|
|
@@ -649,7 +772,20 @@ export function GonvexAuthProvider(props) {
|
|
|
649
772
|
const frame = document.createElement("iframe");
|
|
650
773
|
frame.hidden = true;
|
|
651
774
|
frame.setAttribute("aria-hidden", "true");
|
|
652
|
-
|
|
775
|
+
const receiverMethod = handoff.receiverMethod ?? "get";
|
|
776
|
+
let receiverForm = null;
|
|
777
|
+
if (receiverMethod === "get") {
|
|
778
|
+
frame.src = receiver.toString();
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
const frameName = `gonvex-session-handoff-${nonce}`;
|
|
782
|
+
frame.name = frameName;
|
|
783
|
+
receiverForm = document.createElement("form");
|
|
784
|
+
receiverForm.hidden = true;
|
|
785
|
+
receiverForm.method = "post";
|
|
786
|
+
receiverForm.action = receiver.toString();
|
|
787
|
+
receiverForm.target = frameName;
|
|
788
|
+
}
|
|
653
789
|
await new Promise((resolve, reject) => {
|
|
654
790
|
const timeout = window.setTimeout(() => {
|
|
655
791
|
cleanup();
|
|
@@ -658,6 +794,7 @@ export function GonvexAuthProvider(props) {
|
|
|
658
794
|
const cleanup = () => {
|
|
659
795
|
window.clearTimeout(timeout);
|
|
660
796
|
window.removeEventListener("message", onMessage);
|
|
797
|
+
receiverForm?.remove();
|
|
661
798
|
frame.remove();
|
|
662
799
|
};
|
|
663
800
|
const onMessage = (event) => {
|
|
@@ -683,6 +820,10 @@ export function GonvexAuthProvider(props) {
|
|
|
683
820
|
};
|
|
684
821
|
window.addEventListener("message", onMessage);
|
|
685
822
|
document.body.append(frame);
|
|
823
|
+
if (receiverForm) {
|
|
824
|
+
document.body.append(receiverForm);
|
|
825
|
+
receiverForm.submit();
|
|
826
|
+
}
|
|
686
827
|
});
|
|
687
828
|
}, [props.crossOriginHandoff, props.projectId, runtimeUrl]);
|
|
688
829
|
const inviteMember = useCallback(async (tenantId, email, options) => {
|
|
@@ -719,6 +860,10 @@ export function GonvexAuthProvider(props) {
|
|
|
719
860
|
if (!grant.id || !grant.token || !Number.isFinite(Date.parse(expiresAt))) {
|
|
720
861
|
throw new Error("Gonvex returned an invalid developer grant.");
|
|
721
862
|
}
|
|
863
|
+
// The activation token is consumed by this authentication; the runtime
|
|
864
|
+
// answers with the reconnect credential that a reload must use instead.
|
|
865
|
+
const pending = { token: grant.token };
|
|
866
|
+
pendingDeveloperTokenRef.current = pending;
|
|
722
867
|
try {
|
|
723
868
|
await props.client.authenticate({
|
|
724
869
|
project: props.projectId,
|
|
@@ -738,10 +883,25 @@ export function GonvexAuthProvider(props) {
|
|
|
738
883
|
});
|
|
739
884
|
throw cause;
|
|
740
885
|
}
|
|
741
|
-
|
|
886
|
+
finally {
|
|
887
|
+
if (pendingDeveloperTokenRef.current === pending)
|
|
888
|
+
pendingDeveloperTokenRef.current = null;
|
|
889
|
+
}
|
|
890
|
+
const next = {
|
|
891
|
+
active: true,
|
|
892
|
+
tenantId,
|
|
893
|
+
grantId: grant.id,
|
|
894
|
+
expiresAt,
|
|
895
|
+
accountId: current.account.id,
|
|
896
|
+
originalTenantId: current.activeTenantId,
|
|
897
|
+
clientInstalled: true,
|
|
898
|
+
restoring: false,
|
|
899
|
+
};
|
|
742
900
|
developerModeRef.current = next;
|
|
743
|
-
|
|
744
|
-
|
|
901
|
+
developerTokenRef.current = pending.token;
|
|
902
|
+
writeDeveloperGrant(storageKey, props.projectId, next, pending.token);
|
|
903
|
+
setDeveloperMode(publicDeveloperMode(next));
|
|
904
|
+
}, [props.client, props.projectId, storageKey]);
|
|
745
905
|
const exitDeveloperMode = useCallback(async () => {
|
|
746
906
|
const developer = developerModeRef.current;
|
|
747
907
|
if (!developer?.grantId)
|
|
@@ -756,7 +916,9 @@ export function GonvexAuthProvider(props) {
|
|
|
756
916
|
const activeTenant = session?.tenants.find((tenant) => tenant.id === visibleTenantId) ?? null;
|
|
757
917
|
const authValue = useMemo(() => ({
|
|
758
918
|
isLoading,
|
|
759
|
-
isAuthenticated: Boolean(session
|
|
919
|
+
isAuthenticated: Boolean(session
|
|
920
|
+
&& session.expiresAt > Date.now()
|
|
921
|
+
&& session.refreshExpiresAt > Date.now()),
|
|
760
922
|
sessionState,
|
|
761
923
|
fetchAccessToken,
|
|
762
924
|
account: session?.account ?? null,
|
|
@@ -778,7 +940,7 @@ export function GonvexAuthProvider(props) {
|
|
|
778
940
|
enterDeveloperMode,
|
|
779
941
|
exitDeveloperMode,
|
|
780
942
|
}), [acceptInvitation, activeTenant, createTenant, developerMode, enterDeveloperMode, error, exitDeveloperMode, fetchAccessToken, handoffSessionTo, inviteMember, isLoading, refreshMemberships, revokeInvitation, session, sessionState, setActiveTenant, signIn, signInWithPassword, signInWithProvider, signOut]);
|
|
781
|
-
return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? null : props.children }) }) }));
|
|
943
|
+
return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? (props.loadingFallback ?? null) : props.children }) }) }));
|
|
782
944
|
}
|
|
783
945
|
export function useGonvexAuth() {
|
|
784
946
|
const value = useContext(ManagedAuthContext);
|
|
@@ -901,10 +1063,17 @@ function sessionFromNativeGrant(value, previous) {
|
|
|
901
1063
|
throw new GonvexAuthRequestError("Gonvex returned an invalid native session.", 502);
|
|
902
1064
|
}
|
|
903
1065
|
const session = value;
|
|
904
|
-
|
|
1066
|
+
return preserveActiveTenant(session, previous);
|
|
1067
|
+
}
|
|
1068
|
+
function preserveActiveTenant(session, previous) {
|
|
1069
|
+
const activeTenantId = previous
|
|
1070
|
+
&& previous.account.id === session.account.id
|
|
1071
|
+
&& session.tenants.some((tenant) => tenant.id === previous.activeTenantId)
|
|
905
1072
|
? previous.activeTenantId
|
|
906
1073
|
: session.activeTenantId;
|
|
907
|
-
return
|
|
1074
|
+
return activeTenantId === session.activeTenantId
|
|
1075
|
+
? session
|
|
1076
|
+
: { ...session, activeTenantId };
|
|
908
1077
|
}
|
|
909
1078
|
async function requestGonvexAuthToken(runtimeUrl, body) {
|
|
910
1079
|
const controller = new AbortController();
|
|
@@ -929,7 +1098,16 @@ async function requestGonvexAuthToken(runtimeUrl, body) {
|
|
|
929
1098
|
}
|
|
930
1099
|
}
|
|
931
1100
|
function isFatalRefreshError(cause) {
|
|
932
|
-
|
|
1101
|
+
if (cause instanceof GonvexAuthRequestError) {
|
|
1102
|
+
return cause.status === 400 || cause.status === 401 || cause.status === 403;
|
|
1103
|
+
}
|
|
1104
|
+
if (!(cause instanceof GonvexClientError))
|
|
1105
|
+
return false;
|
|
1106
|
+
if (cause.code === "auth")
|
|
1107
|
+
return true;
|
|
1108
|
+
if (cause.code !== "server")
|
|
1109
|
+
return false;
|
|
1110
|
+
return /invalid or expired refresh token|refresh token (?:reuse detected|was already rotated)|login was revoked/i.test(cause.message);
|
|
933
1111
|
}
|
|
934
1112
|
function isFatalExternalExchangeError(cause) {
|
|
935
1113
|
if (cause instanceof GonvexAuthRequestError) {
|
|
@@ -1017,6 +1195,87 @@ const WARM_SESSION_ACCESS_SAFETY_MS = 30_000;
|
|
|
1017
1195
|
function authSessionScope(projectId, session) {
|
|
1018
1196
|
return `${projectId}\u0000${session.activeTenantId ?? ""}\u0000${session.account.id}`;
|
|
1019
1197
|
}
|
|
1198
|
+
const DEVELOPER_GRANT_RESTORE_MARGIN_MS = 5_000;
|
|
1199
|
+
function developerGrantStoragePrefix(storageKey) {
|
|
1200
|
+
return `${storageKey}:developer:`;
|
|
1201
|
+
}
|
|
1202
|
+
function developerGrantStorageKey(storageKey, accountId) {
|
|
1203
|
+
return `${developerGrantStoragePrefix(storageKey)}${encodeURIComponent(accountId)}`;
|
|
1204
|
+
}
|
|
1205
|
+
function publicDeveloperMode(developer) {
|
|
1206
|
+
return { active: true, tenantId: developer.tenantId, grantId: developer.grantId, expiresAt: developer.expiresAt };
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Developer grants live in sessionStorage: they survive a reload of the same
|
|
1210
|
+
* tab but never reach other tabs or outlive the browser session.
|
|
1211
|
+
*/
|
|
1212
|
+
function readDeveloperGrant(storageKey, projectId, accountId) {
|
|
1213
|
+
if (typeof window === "undefined")
|
|
1214
|
+
return null;
|
|
1215
|
+
const key = developerGrantStorageKey(storageKey, accountId);
|
|
1216
|
+
let parsed = null;
|
|
1217
|
+
try {
|
|
1218
|
+
parsed = JSON.parse(sessionStorage.getItem(key) ?? "null");
|
|
1219
|
+
}
|
|
1220
|
+
catch {
|
|
1221
|
+
parsed = null;
|
|
1222
|
+
}
|
|
1223
|
+
if (!parsed) {
|
|
1224
|
+
safeSessionStorageRemove(key);
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
const expiresAt = typeof parsed.expiresAt === "string" ? Date.parse(parsed.expiresAt) : Number.NaN;
|
|
1228
|
+
if (parsed.projectId !== projectId
|
|
1229
|
+
|| parsed.accountId !== accountId
|
|
1230
|
+
|| typeof parsed.tenantId !== "string" || !parsed.tenantId
|
|
1231
|
+
|| typeof parsed.grantId !== "string" || !parsed.grantId
|
|
1232
|
+
|| typeof parsed.token !== "string" || !(parsed.token.startsWith("gvx_imp_") || parsed.token.startsWith("gvx_dev_"))
|
|
1233
|
+
|| !Number.isFinite(expiresAt)
|
|
1234
|
+
|| expiresAt <= Date.now() + DEVELOPER_GRANT_RESTORE_MARGIN_MS) {
|
|
1235
|
+
safeSessionStorageRemove(key);
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
return parsed;
|
|
1239
|
+
}
|
|
1240
|
+
function writeDeveloperGrant(storageKey, projectId, developer, token) {
|
|
1241
|
+
const grant = {
|
|
1242
|
+
projectId,
|
|
1243
|
+
accountId: developer.accountId,
|
|
1244
|
+
tenantId: developer.tenantId,
|
|
1245
|
+
grantId: developer.grantId,
|
|
1246
|
+
token,
|
|
1247
|
+
expiresAt: developer.expiresAt,
|
|
1248
|
+
...(developer.originalTenantId ? { originalTenantId: developer.originalTenantId } : {}),
|
|
1249
|
+
};
|
|
1250
|
+
safeSessionStorageSet(developerGrantStorageKey(storageKey, developer.accountId), JSON.stringify(grant));
|
|
1251
|
+
}
|
|
1252
|
+
/** Removes every stored developer grant for this project, except `keepAccountId`'s. */
|
|
1253
|
+
function clearDeveloperGrants(storageKey, keepAccountId) {
|
|
1254
|
+
if (typeof window === "undefined")
|
|
1255
|
+
return;
|
|
1256
|
+
const prefix = developerGrantStoragePrefix(storageKey);
|
|
1257
|
+
const keep = keepAccountId ? developerGrantStorageKey(storageKey, keepAccountId) : undefined;
|
|
1258
|
+
try {
|
|
1259
|
+
const keys = [];
|
|
1260
|
+
for (let index = 0; index < sessionStorage.length; index += 1) {
|
|
1261
|
+
const key = sessionStorage.key(index);
|
|
1262
|
+
if (key?.startsWith(prefix) && key !== keep)
|
|
1263
|
+
keys.push(key);
|
|
1264
|
+
}
|
|
1265
|
+
for (const key of keys)
|
|
1266
|
+
sessionStorage.removeItem(key);
|
|
1267
|
+
}
|
|
1268
|
+
catch { /* storage can be unavailable in hardened browsers */ }
|
|
1269
|
+
}
|
|
1270
|
+
function installDeveloperClientAuth(client, projectId, developer, token) {
|
|
1271
|
+
client.setAuth({
|
|
1272
|
+
project: projectId,
|
|
1273
|
+
tenant: developer.tenantId,
|
|
1274
|
+
token,
|
|
1275
|
+
fetchToken: undefined,
|
|
1276
|
+
identity: { sub: developer.accountId, iss: projectId },
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1020
1279
|
function installClientSession(client, projectId, session) {
|
|
1021
1280
|
client.setAuth({
|
|
1022
1281
|
project: projectId,
|
|
@@ -1037,21 +1296,44 @@ function sameInstalledAuthSession(current, next) {
|
|
|
1037
1296
|
* has a current access token. A refresh credential alone is not authorization
|
|
1038
1297
|
* to expose cached tenant data.
|
|
1039
1298
|
*/
|
|
1040
|
-
function reusableExternalAuthSession(session, initialTenantId, provider, now = Date.now()) {
|
|
1299
|
+
function reusableExternalAuthSession(session, initialTenantId, provider, hasExplicitInitialTenant = initialTenantId !== undefined, now = Date.now()) {
|
|
1041
1300
|
if (!session || session.account.provider !== provider)
|
|
1042
1301
|
return null;
|
|
1043
1302
|
if (!Number.isFinite(session.expiresAt) || session.expiresAt <= now + WARM_SESSION_ACCESS_SAFETY_MS) {
|
|
1044
1303
|
return null;
|
|
1045
1304
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1305
|
+
const scoped = scopeSessionForInitialTenant(session, initialTenantId, hasExplicitInitialTenant);
|
|
1306
|
+
if (!scoped)
|
|
1307
|
+
return null;
|
|
1308
|
+
if (hasExplicitInitialTenant
|
|
1309
|
+
&& initialTenantId !== undefined
|
|
1310
|
+
&& !scoped.tenants.some((tenant) => (tenant.id === scoped.activeTenantId
|
|
1048
1311
|
&& (tenant.id === initialTenantId || tenant.domain.toLowerCase() === initialTenantId.toLowerCase()))))
|
|
1049
1312
|
return null;
|
|
1050
|
-
if (
|
|
1051
|
-
&& !
|
|
1313
|
+
if (scoped.activeTenantId !== undefined
|
|
1314
|
+
&& !scoped.tenants.some((tenant) => tenant.id === scoped.activeTenantId)) {
|
|
1052
1315
|
return null;
|
|
1053
1316
|
}
|
|
1054
|
-
return
|
|
1317
|
+
return scoped;
|
|
1318
|
+
}
|
|
1319
|
+
/** Restrict a persisted session to the tenant context selected by the host. */
|
|
1320
|
+
function scopeSessionForInitialTenant(session, initialTenantId, hasExplicitInitialTenant) {
|
|
1321
|
+
if (!session || !hasExplicitInitialTenant)
|
|
1322
|
+
return session;
|
|
1323
|
+
// The landlord is account-scoped. Reuse the valid token and tenant directory,
|
|
1324
|
+
// but never carry a tenant Replica scope onto that origin.
|
|
1325
|
+
if (initialTenantId === undefined) {
|
|
1326
|
+
return session.activeTenantId === undefined
|
|
1327
|
+
? session
|
|
1328
|
+
: { ...session, activeTenantId: undefined };
|
|
1329
|
+
}
|
|
1330
|
+
const tenant = session.tenants.find((candidate) => (candidate.id === initialTenantId
|
|
1331
|
+
|| candidate.domain.toLowerCase() === initialTenantId.toLowerCase()));
|
|
1332
|
+
if (!tenant)
|
|
1333
|
+
return null;
|
|
1334
|
+
return session.activeTenantId === tenant.id
|
|
1335
|
+
? session
|
|
1336
|
+
: { ...session, activeTenantId: tenant.id };
|
|
1055
1337
|
}
|
|
1056
1338
|
function readAuthSession(key) {
|
|
1057
1339
|
if (typeof window === "undefined")
|
|
@@ -1124,7 +1406,7 @@ export function useQueryResult(ref, args = {}, options = {}) {
|
|
|
1124
1406
|
const sessionScopeGeneration = useSessionScopeGeneration(client);
|
|
1125
1407
|
const path = ref.path;
|
|
1126
1408
|
const kind = ref.kind;
|
|
1127
|
-
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
1409
|
+
const optimisticKey = useMemo(() => JSON.stringify(ref.optimistic ?? null), [ref.optimistic]);
|
|
1128
1410
|
const argsKey = JSON.stringify(args);
|
|
1129
1411
|
const keepPreviousData = options.keepPreviousData !== false;
|
|
1130
1412
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LIVE_QUERY_SLOW_MS;
|
|
@@ -1219,10 +1501,10 @@ export function useLiveQuery(ref, args = {}) {
|
|
|
1219
1501
|
}
|
|
1220
1502
|
const path = ref.path;
|
|
1221
1503
|
const kind = ref.kind;
|
|
1222
|
-
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
1504
|
+
const optimisticKey = useMemo(() => JSON.stringify(ref.optimistic ?? null), [ref.optimistic]);
|
|
1223
1505
|
const argsKey = JSON.stringify(args);
|
|
1224
|
-
const liveKey = JSON.stringify(ref.live ?? null);
|
|
1225
|
-
const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args),
|
|
1506
|
+
const liveKey = useMemo(() => JSON.stringify(ref.live ?? null), [ref.live]);
|
|
1507
|
+
const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args, { deferStart: true }),
|
|
1226
1508
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1227
1509
|
[client, kind, path, optimisticKey, argsKey, liveKey]);
|
|
1228
1510
|
const liveResult = useSyncExternalStore(useCallback((notify) => liveWatch?.onUpdate(notify) ?? (() => undefined), [liveWatch]), useCallback(() => liveWatch?.localLiveQueryResult(), [liveWatch]), () => undefined);
|
|
@@ -1235,21 +1517,58 @@ export function useControlQuery(ref, args = {}) {
|
|
|
1235
1517
|
const watch = useMemo(() => args === "skip" ? undefined : client.watchControlQuery(ref, args),
|
|
1236
1518
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1237
1519
|
[client, ref.kind, ref.path, argsKey]);
|
|
1238
|
-
const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined
|
|
1520
|
+
const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined,
|
|
1521
|
+
// Batch cold control-query delivery, but publish later control changes
|
|
1522
|
+
// before their Reducer promise settles and follow-up UI handlers run.
|
|
1523
|
+
true);
|
|
1239
1524
|
return snapshot?.result;
|
|
1240
1525
|
}
|
|
1241
1526
|
/** Read one normalized entity from the single Gonvex Local Replica. */
|
|
1242
1527
|
export function useEntity(entity, id) {
|
|
1243
1528
|
const client = useGonvexClient();
|
|
1244
|
-
|
|
1245
|
-
|
|
1529
|
+
useEffect(() => client.retainReplicaEntities(entity, [id]), [client, entity, id]);
|
|
1530
|
+
const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.entityVersion(entity, id), [client, entity, id]), () => 0);
|
|
1531
|
+
return useMemo(() => client.localReplica.entity(entity, id), [client, entity, id, version]);
|
|
1246
1532
|
}
|
|
1247
1533
|
/** Resolve an ordered entity batch with one Local Replica subscription. */
|
|
1248
1534
|
export function useReplicaEntities(entity, ids) {
|
|
1249
1535
|
const client = useGonvexClient();
|
|
1250
1536
|
const idsKey = JSON.stringify(ids);
|
|
1251
|
-
|
|
1252
|
-
|
|
1537
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1538
|
+
useEffect(() => client.retainReplicaEntities(entity, ids), [client, entity, idsKey]);
|
|
1539
|
+
const cache = useMemo(() => new Map(), [client, entity]);
|
|
1540
|
+
const getVersion = useMemo(() => {
|
|
1541
|
+
let tableVersion;
|
|
1542
|
+
let snapshot = "";
|
|
1543
|
+
return () => {
|
|
1544
|
+
const next = client.localReplica.entityVersion(entity);
|
|
1545
|
+
// Metadata, presence and other tables publish through this subscription
|
|
1546
|
+
// too. They cannot change this batch, so avoid scanning its IDs again.
|
|
1547
|
+
if (next !== tableVersion) {
|
|
1548
|
+
tableVersion = next;
|
|
1549
|
+
snapshot = ids.map(id => client.localReplica.entityVersion(entity, id)).join(",");
|
|
1550
|
+
}
|
|
1551
|
+
return snapshot;
|
|
1552
|
+
};
|
|
1553
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1554
|
+
}, [client, entity, idsKey]);
|
|
1555
|
+
const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), getVersion, () => "");
|
|
1556
|
+
return useMemo(() => {
|
|
1557
|
+
const retained = new Set(ids);
|
|
1558
|
+
for (const id of cache.keys())
|
|
1559
|
+
if (!retained.has(id))
|
|
1560
|
+
cache.delete(id);
|
|
1561
|
+
return ids.map(id => {
|
|
1562
|
+
const rowVersion = client.localReplica.entityVersion(entity, id);
|
|
1563
|
+
const prior = cache.get(id);
|
|
1564
|
+
if (prior?.version === rowVersion)
|
|
1565
|
+
return prior.row;
|
|
1566
|
+
const row = client.localReplica.entity(entity, id);
|
|
1567
|
+
cache.set(id, { version: rowVersion, row });
|
|
1568
|
+
return row;
|
|
1569
|
+
});
|
|
1570
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1571
|
+
}, [client, entity, idsKey, version, cache]);
|
|
1253
1572
|
}
|
|
1254
1573
|
/** Read a persisted Live Query window without opening another server subscription. */
|
|
1255
1574
|
export function useRetainedLiveQuery(signatureOrReference, args = {}) {
|
|
@@ -1258,9 +1577,10 @@ export function useRetainedLiveQuery(signatureOrReference, args = {}) {
|
|
|
1258
1577
|
const signature = typeof signatureOrReference === "string"
|
|
1259
1578
|
? signatureOrReference
|
|
1260
1579
|
: client.replicaSignature(signatureOrReference, args);
|
|
1261
|
-
|
|
1262
|
-
return
|
|
1580
|
+
useEffect(() => client.retainReplicaWindow(signature), [client, signature]);
|
|
1581
|
+
return useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.liveQuerySnapshot(signature), [client, signature, argsKey]), () => emptyLiveQuery);
|
|
1263
1582
|
}
|
|
1583
|
+
const emptyLiveQuery = { rows: [], ids: [], source: 'cache', completeness: 'partial', freshness: 'verifying' };
|
|
1264
1584
|
/** Structured Live Query state backed by normalized Local Replica entities. */
|
|
1265
1585
|
export function useLiveQueryState(ref, args = {}) {
|
|
1266
1586
|
const client = useGonvexClient();
|
|
@@ -1269,28 +1589,41 @@ export function useLiveQueryState(ref, args = {}) {
|
|
|
1269
1589
|
useEffect(() => {
|
|
1270
1590
|
if (args === "skip")
|
|
1271
1591
|
return;
|
|
1272
|
-
|
|
1592
|
+
const release = client.retainReplicaWindow(signature);
|
|
1593
|
+
const unsubscribe = client.subscribeLiveQuery(ref, args, () => undefined);
|
|
1594
|
+
return () => { unsubscribe(); release(); };
|
|
1273
1595
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1274
1596
|
}, [client, ref.kind, ref.path, argsKey]);
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
return {
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1597
|
+
const getSnapshot = useMemo(() => {
|
|
1598
|
+
let offlineVersion = -1;
|
|
1599
|
+
let offlineResult;
|
|
1600
|
+
return () => {
|
|
1601
|
+
if (args === 'skip')
|
|
1602
|
+
return emptyLiveQuery;
|
|
1603
|
+
if (client.localReplica.freshness() !== 'offline')
|
|
1604
|
+
return client.localReplica.liveQuerySnapshot(signature);
|
|
1605
|
+
const version = client.localReplica.version();
|
|
1606
|
+
if (offlineVersion === version && offlineResult)
|
|
1607
|
+
return offlineResult;
|
|
1608
|
+
const offline = client.offlineLiveQuery(ref, args);
|
|
1609
|
+
offlineResult = {
|
|
1610
|
+
rows: offline.rows,
|
|
1611
|
+
ids: offline.rows.map((row) => String(row.id ?? row._id ?? "")).filter(Boolean),
|
|
1612
|
+
...(offline.total === undefined ? {} : { total: offline.total }),
|
|
1613
|
+
...(offline.offset === undefined ? {} : { offset: offline.offset }),
|
|
1614
|
+
...(offline.limit === undefined ? {} : { limit: offline.limit }),
|
|
1615
|
+
source: "cache",
|
|
1616
|
+
completeness: offline.completeness,
|
|
1617
|
+
freshness: "offline",
|
|
1618
|
+
supported: offline.supported,
|
|
1619
|
+
...(offline.unsupportedOperator ? { unsupportedOperator: offline.unsupportedOperator } : {}),
|
|
1620
|
+
};
|
|
1621
|
+
offlineVersion = version;
|
|
1622
|
+
return offlineResult;
|
|
1289
1623
|
};
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
: { rows: [], ids: [], source: "cache", completeness: "partial", freshness: client.localReplica.freshness() };
|
|
1624
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1625
|
+
}, [client, ref.kind, ref.path, signature, argsKey]);
|
|
1626
|
+
return useSyncExternalStore(useCallback(notify => signature ? client.localReplica.subscribe(notify) : () => undefined, [client, signature]), getSnapshot, () => emptyLiveQuery);
|
|
1294
1627
|
}
|
|
1295
1628
|
/** Execute a read-only Query once. Queries never subscribe or rerun. */
|
|
1296
1629
|
export function useQuery(ref, args = {}) {
|
|
@@ -1325,9 +1658,9 @@ export function useReplicaCollection(ref, args = {}) {
|
|
|
1325
1658
|
const client = useGonvexClient();
|
|
1326
1659
|
const path = ref.path;
|
|
1327
1660
|
const kind = ref.kind;
|
|
1328
|
-
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
1661
|
+
const optimisticKey = useMemo(() => JSON.stringify(ref.optimistic ?? null), [ref.optimistic]);
|
|
1329
1662
|
const argsKey = JSON.stringify(args);
|
|
1330
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
1663
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args, { deferStart: true }),
|
|
1331
1664
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1332
1665
|
[client, kind, path, optimisticKey, argsKey]);
|
|
1333
1666
|
return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaResult(), [watch]), () => undefined);
|
|
@@ -1337,22 +1670,75 @@ export function useReplicaCollectionState(ref, args = {}) {
|
|
|
1337
1670
|
const client = useGonvexClient();
|
|
1338
1671
|
const path = ref.path;
|
|
1339
1672
|
const argsKey = JSON.stringify(args);
|
|
1340
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
1673
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args, { deferStart: true }),
|
|
1341
1674
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1342
1675
|
[client, ref.kind, path, argsKey]);
|
|
1343
1676
|
return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaState(), [watch]), () => undefined);
|
|
1344
1677
|
}
|
|
1678
|
+
/** Subscribe to a bounded, dynamic set of slices of the same Replica Collection.
|
|
1679
|
+
* Results borrow normalized SDK snapshots; there is no second entity cache.
|
|
1680
|
+
* Deferred watches only start when React commits the subscription.
|
|
1681
|
+
*/
|
|
1682
|
+
export function useReplicaCollectionStates(ref, args) {
|
|
1683
|
+
const client = useGonvexClient();
|
|
1684
|
+
const argsKey = JSON.stringify(args);
|
|
1685
|
+
const watches = useMemo(() => args.map(value => client.watchReplica(ref, value, { deferStart: true })),
|
|
1686
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1687
|
+
[client, ref.kind, ref.path, argsKey]);
|
|
1688
|
+
const getSnapshot = useMemo(() => {
|
|
1689
|
+
let snapshot = [];
|
|
1690
|
+
return () => {
|
|
1691
|
+
const next = watches.map(watch => watch.localReplicaState());
|
|
1692
|
+
if (next.length !== snapshot.length || next.some((state, index) => state !== snapshot[index]))
|
|
1693
|
+
snapshot = next;
|
|
1694
|
+
return snapshot;
|
|
1695
|
+
};
|
|
1696
|
+
}, [watches]);
|
|
1697
|
+
return useSyncExternalStore(useCallback(notify => {
|
|
1698
|
+
const releases = watches.map(watch => watch.onUpdate(notify));
|
|
1699
|
+
return () => releases.forEach(release => release());
|
|
1700
|
+
}, [watches]), getSnapshot, () => emptyCollectionStates);
|
|
1701
|
+
}
|
|
1702
|
+
const emptyCollectionStates = [];
|
|
1703
|
+
/** Select rows and authority metadata without rerendering for unused watermarks. */
|
|
1704
|
+
export function useReplicaCollectionStateSelector(ref, args, selector, isEqual = Object.is) {
|
|
1705
|
+
const client = useGonvexClient();
|
|
1706
|
+
const argsKey = JSON.stringify(args);
|
|
1707
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args, { deferStart: true }),
|
|
1708
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1709
|
+
[client, ref.kind, ref.path, argsKey]);
|
|
1710
|
+
const committed = useRef(undefined);
|
|
1711
|
+
const getSnapshot = useMemo(() => {
|
|
1712
|
+
let initialized = false;
|
|
1713
|
+
let previous;
|
|
1714
|
+
let selected;
|
|
1715
|
+
return () => {
|
|
1716
|
+
const state = watch?.localReplicaState();
|
|
1717
|
+
if (initialized && state === previous)
|
|
1718
|
+
return selected;
|
|
1719
|
+
const next = state === undefined ? undefined : selector(state);
|
|
1720
|
+
const prior = initialized ? selected : committed.current && committed.current.watch === watch ? committed.current.value : undefined;
|
|
1721
|
+
selected = prior !== undefined && next !== undefined && isEqual(prior, next) ? prior : next;
|
|
1722
|
+
previous = state;
|
|
1723
|
+
initialized = true;
|
|
1724
|
+
return selected;
|
|
1725
|
+
};
|
|
1726
|
+
}, [watch, selector, isEqual]);
|
|
1727
|
+
const selected = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), getSnapshot, () => undefined);
|
|
1728
|
+
useEffect(() => { committed.current = { watch, value: selected }; }, [watch, selected]);
|
|
1729
|
+
return selected;
|
|
1730
|
+
}
|
|
1345
1731
|
export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
|
|
1346
1732
|
const client = useGonvexClient();
|
|
1347
1733
|
const path = ref.path;
|
|
1348
1734
|
const kind = ref.kind;
|
|
1349
|
-
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
1735
|
+
const optimisticKey = useMemo(() => JSON.stringify(ref.optimistic ?? null), [ref.optimistic]);
|
|
1350
1736
|
const argsKey = JSON.stringify(args);
|
|
1351
1737
|
const selectorRef = useRef(selector);
|
|
1352
1738
|
const equalityRef = useRef(isEqual);
|
|
1353
1739
|
selectorRef.current = selector;
|
|
1354
1740
|
equalityRef.current = isEqual;
|
|
1355
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
1741
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args, { deferStart: true }),
|
|
1356
1742
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1357
1743
|
[client, kind, path, optimisticKey, argsKey]);
|
|
1358
1744
|
const selectedRef = useRef({
|
|
@@ -1404,6 +1790,7 @@ export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
1404
1790
|
}
|
|
1405
1791
|
export function useReducer(ref, options = {}) {
|
|
1406
1792
|
const client = useGonvexClient();
|
|
1793
|
+
useEffect(() => { void client.prepareReducer?.(ref)?.catch(() => undefined); }, [client, ref]);
|
|
1407
1794
|
const refRef = useRef(ref);
|
|
1408
1795
|
refRef.current = ref;
|
|
1409
1796
|
const timeoutMs = options.timeoutMs;
|
|
@@ -1437,6 +1824,69 @@ export function useGonvexConnectionState() {
|
|
|
1437
1824
|
}, [client]);
|
|
1438
1825
|
return state;
|
|
1439
1826
|
}
|
|
1827
|
+
const EMPTY_INTENTS = [];
|
|
1828
|
+
const NO_SUBSCRIPTION = () => () => undefined;
|
|
1829
|
+
/**
|
|
1830
|
+
* Every durable reducer intent of the signed-in identity, oldest first:
|
|
1831
|
+
* pending and inflight intents, intents parked as `failed` after exhausting
|
|
1832
|
+
* their retry budget, and server-`rejected` intents awaiting dismissal.
|
|
1833
|
+
* Use `client.retryIntent(id)` / `client.discardIntent(id)` to act on them.
|
|
1834
|
+
*/
|
|
1835
|
+
export function useOutboxIntents() {
|
|
1836
|
+
const client = useGonvexClient();
|
|
1837
|
+
const subscribe = useMemo(() => (typeof client.subscribeIntents === "function" ? client.subscribeIntents.bind(client) : NO_SUBSCRIPTION), [client]);
|
|
1838
|
+
const getSnapshot = useCallback(() => (typeof client.intentsSnapshot === "function" ? client.intentsSnapshot() : EMPTY_INTENTS), [client]);
|
|
1839
|
+
return useReactSyncExternalStore(subscribe, getSnapshot, () => EMPTY_INTENTS);
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Delivery status of one row: `failed` or `rejected` when an intent that
|
|
1843
|
+
* touched it needs attention, `syncing` while one is still queued, and
|
|
1844
|
+
* undefined once everything touching it has been acknowledged.
|
|
1845
|
+
*/
|
|
1846
|
+
export function useEntityIntentStatus(entity, id) {
|
|
1847
|
+
const intents = useOutboxIntents();
|
|
1848
|
+
return useMemo(() => (id ? entityStatusFromIntents(intents, entity, id) : undefined), [intents, entity, id]);
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Observe locally executing Reducers that fell back to a queued-only intent
|
|
1852
|
+
* (no local result), for logging. The latest listener is always called; it
|
|
1853
|
+
* does not need to be memoized.
|
|
1854
|
+
*/
|
|
1855
|
+
export function useLocalExecutionFallback(listener) {
|
|
1856
|
+
const client = useGonvexClient();
|
|
1857
|
+
const latest = useRef(listener);
|
|
1858
|
+
useEffect(() => { latest.current = listener; });
|
|
1859
|
+
useEffect(() => {
|
|
1860
|
+
if (typeof client.onLocalExecutionFallback !== "function")
|
|
1861
|
+
return undefined;
|
|
1862
|
+
return client.onLocalExecutionFallback((event) => latest.current(event));
|
|
1863
|
+
}, [client]);
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Drive a "Clear cache" control: discards the persisted Local Replica and
|
|
1867
|
+
* rehydrates it from the server, keeping queued intents unless asked.
|
|
1868
|
+
*/
|
|
1869
|
+
export function useResetLocalReplica() {
|
|
1870
|
+
const client = useGonvexClient();
|
|
1871
|
+
const [isResetting, setResetting] = useState(false);
|
|
1872
|
+
const [error, setError] = useState();
|
|
1873
|
+
const reset = useCallback(async (options) => {
|
|
1874
|
+
setResetting(true);
|
|
1875
|
+
setError(undefined);
|
|
1876
|
+
try {
|
|
1877
|
+
return await client.resetLocalReplica(options);
|
|
1878
|
+
}
|
|
1879
|
+
catch (cause) {
|
|
1880
|
+
const failure = cause instanceof Error ? cause : new Error(String(cause));
|
|
1881
|
+
setError(failure);
|
|
1882
|
+
throw failure;
|
|
1883
|
+
}
|
|
1884
|
+
finally {
|
|
1885
|
+
setResetting(false);
|
|
1886
|
+
}
|
|
1887
|
+
}, [client]);
|
|
1888
|
+
return { reset, isResetting, error };
|
|
1889
|
+
}
|
|
1440
1890
|
export function useGonvexClient() {
|
|
1441
1891
|
const client = useContext(GonvexContext);
|
|
1442
1892
|
if (!client)
|