@oxy.so/services 13.12.0 → 13.13.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/README.md +7 -0
- package/lib/commonjs/ui/client.js +7 -0
- package/lib/commonjs/ui/client.js.map +1 -1
- package/lib/commonjs/ui/context/commitSessionFlow.js +4 -0
- package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +17 -2
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/context/oxyContextHelpers.js +2 -0
- package/lib/commonjs/ui/context/oxyContextHelpers.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollowTarget.js +9 -1
- package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSessionManagement.js +3 -0
- package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/commonjs/ui/index.js +7 -0
- package/lib/commonjs/ui/index.js.map +1 -1
- package/lib/commonjs/ui/runtime/createOxyRuntime.js +1 -0
- package/lib/commonjs/ui/runtime/createOxyRuntime.js.map +1 -1
- package/lib/commonjs/ui/stores/followStore.js +14 -0
- package/lib/commonjs/ui/stores/followStore.js.map +1 -1
- package/lib/commonjs/ui/stores/followTargetStore.js +15 -5
- package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -1
- package/lib/commonjs/ui/utils/errorHandlers.js +2 -0
- package/lib/commonjs/ui/utils/errorHandlers.js.map +1 -1
- package/lib/commonjs/ui/utils/followErrors.js +2 -0
- package/lib/commonjs/ui/utils/followErrors.js.map +1 -1
- package/lib/commonjs/ui/utils/organizationDraft.js +17 -0
- package/lib/commonjs/ui/utils/organizationDraft.js.map +1 -0
- package/lib/commonjs/ui/utils/sessionChangedError.js +20 -0
- package/lib/commonjs/ui/utils/sessionChangedError.js.map +1 -0
- package/lib/commonjs/ui/utils/sessionHelpers.js +2 -0
- package/lib/commonjs/ui/utils/sessionHelpers.js.map +1 -1
- package/lib/module/ui/client.js +3 -0
- package/lib/module/ui/client.js.map +1 -1
- package/lib/module/ui/context/commitSessionFlow.js +4 -0
- package/lib/module/ui/context/commitSessionFlow.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +17 -2
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/context/oxyContextHelpers.js +3 -0
- package/lib/module/ui/context/oxyContextHelpers.js.map +1 -1
- package/lib/module/ui/hooks/useFollowTarget.js +10 -2
- package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
- package/lib/module/ui/hooks/useSessionManagement.js +3 -0
- package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/module/ui/index.js +3 -0
- package/lib/module/ui/index.js.map +1 -1
- package/lib/module/ui/runtime/createOxyRuntime.js +1 -0
- package/lib/module/ui/runtime/createOxyRuntime.js.map +1 -1
- package/lib/module/ui/stores/followStore.js +14 -0
- package/lib/module/ui/stores/followStore.js.map +1 -1
- package/lib/module/ui/stores/followTargetStore.js +14 -5
- package/lib/module/ui/stores/followTargetStore.js.map +1 -1
- package/lib/module/ui/utils/errorHandlers.js +2 -0
- package/lib/module/ui/utils/errorHandlers.js.map +1 -1
- package/lib/module/ui/utils/followErrors.js +3 -0
- package/lib/module/ui/utils/followErrors.js.map +1 -1
- package/lib/module/ui/utils/organizationDraft.js +13 -0
- package/lib/module/ui/utils/organizationDraft.js.map +1 -0
- package/lib/module/ui/utils/sessionChangedError.js +15 -0
- package/lib/module/ui/utils/sessionChangedError.js.map +1 -0
- package/lib/module/ui/utils/sessionHelpers.js +3 -0
- package/lib/module/ui/utils/sessionHelpers.js.map +1 -1
- package/lib/typescript/commonjs/ui/client.d.ts +1 -0
- package/lib/typescript/commonjs/ui/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextHelpers.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts +1 -0
- package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/runtime/createOxyRuntime.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +2 -0
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/errorHandlers.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/followErrors.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/organizationDraft.d.ts +9 -0
- package/lib/typescript/commonjs/ui/utils/organizationDraft.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/sessionChangedError.d.ts +8 -0
- package/lib/typescript/commonjs/ui/utils/sessionChangedError.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts.map +1 -1
- package/lib/typescript/module/ui/client.d.ts +1 -0
- package/lib/typescript/module/ui/client.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextHelpers.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/index.d.ts +1 -0
- package/lib/typescript/module/ui/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/runtime/createOxyRuntime.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts +2 -0
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/errorHandlers.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/followErrors.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/organizationDraft.d.ts +9 -0
- package/lib/typescript/module/ui/utils/organizationDraft.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/sessionChangedError.d.ts +8 -0
- package/lib/typescript/module/ui/utils/sessionChangedError.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/sessionHelpers.d.ts.map +1 -1
- package/package.json +14 -2
- package/src/ui/client.ts +3 -0
- package/src/ui/context/__tests__/commitSessionFlow.test.ts +36 -0
- package/src/ui/context/commitSessionFlow.ts +4 -0
- package/src/ui/context/hooks/useAuthOperations.ts +14 -2
- package/src/ui/context/oxyContextHelpers.ts +3 -0
- package/src/ui/hooks/useFollowTarget.ts +10 -1
- package/src/ui/hooks/useSessionManagement.ts +3 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/runtime/createOxyRuntime.ts +1 -0
- package/src/ui/stores/__tests__/followStore.test.ts +62 -0
- package/src/ui/stores/followStore.ts +14 -0
- package/src/ui/stores/followTargetStore.ts +11 -1
- package/src/ui/utils/errorHandlers.ts +2 -0
- package/src/ui/utils/followErrors.ts +3 -0
- package/src/ui/utils/organizationDraft.ts +17 -0
- package/src/ui/utils/sessionChangedError.ts +14 -0
- package/src/ui/utils/sessionHelpers.ts +2 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { User } from '@oxy.so/core';
|
|
2
2
|
import { logger as loggerUtil } from '@oxy.so/core';
|
|
3
3
|
import { isUnauthorizedStatus } from './oxyContextHelpers';
|
|
4
|
+
import { isSessionChangedError } from '../utils/sessionChangedError';
|
|
4
5
|
|
|
5
6
|
const LOG_CONTEXT = { component: 'OxyContext', method: 'commitSession' } as const;
|
|
6
7
|
|
|
@@ -79,6 +80,7 @@ export async function commitDeviceSetAndResolve(
|
|
|
79
80
|
};
|
|
80
81
|
|
|
81
82
|
const logReconcileError = (registrationError: unknown): void => {
|
|
83
|
+
if (isSessionChangedError(registrationError)) return;
|
|
82
84
|
// A 401 here is the EXPECTED signed-out edge: the bearer was stale/cleared, so
|
|
83
85
|
// the device-set registration (`POST /session/device/add`) cannot carry auth.
|
|
84
86
|
// This is not a failure — the cold boot re-registers on the next load once a
|
|
@@ -107,6 +109,7 @@ export async function commitDeviceSetAndResolve(
|
|
|
107
109
|
try {
|
|
108
110
|
fullUser = await getCurrentUser();
|
|
109
111
|
} catch (profileError) {
|
|
112
|
+
if (isSessionChangedError(profileError)) throw profileError;
|
|
110
113
|
if (__DEV__) {
|
|
111
114
|
loggerUtil.debug(
|
|
112
115
|
'Failed to fetch full user on commit; using minimal fallback',
|
|
@@ -137,6 +140,7 @@ export async function commitDeviceSetAndResolve(
|
|
|
137
140
|
try {
|
|
138
141
|
await reconcileDeviceSet();
|
|
139
142
|
} catch (registrationError) {
|
|
143
|
+
if (isSessionChangedError(registrationError)) throw registrationError;
|
|
140
144
|
logReconcileError(registrationError);
|
|
141
145
|
}
|
|
142
146
|
await hydrateAndResolve();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
2
|
import { logoutIsolatedOAuthSession, readIsolatedOAuthSession } from '../isolatedOAuthSession';
|
|
3
|
+
import { isSessionChangedError } from '../../utils/sessionChangedError';
|
|
3
4
|
import type { ApiError, User } from '@oxy.so/core';
|
|
4
5
|
import type { AuthStateStore, IdentityBinding, SessionClient } from '@oxy.so/core/session';
|
|
5
6
|
import type { ClientSession, SessionLoginResponse } from '@oxy.so/core';
|
|
@@ -174,6 +175,7 @@ export const useAuthOperations = ({
|
|
|
174
175
|
});
|
|
175
176
|
await refreshPinnedAccountId?.();
|
|
176
177
|
} catch (pinError) {
|
|
178
|
+
if (isSessionChangedError(pinError)) throw pinError;
|
|
177
179
|
logger?.('Failed to persist identity pin after sign-in', pinError);
|
|
178
180
|
}
|
|
179
181
|
}
|
|
@@ -192,6 +194,7 @@ export const useAuthOperations = ({
|
|
|
192
194
|
await syncFromClient();
|
|
193
195
|
deviceRegistrationCommitted = true;
|
|
194
196
|
} catch (registrationError) {
|
|
197
|
+
if (isSessionChangedError(registrationError)) throw registrationError;
|
|
195
198
|
logger?.('Failed to register sign-in into device session set', registrationError);
|
|
196
199
|
}
|
|
197
200
|
|
|
@@ -214,6 +217,7 @@ export const useAuthOperations = ({
|
|
|
214
217
|
},
|
|
215
218
|
);
|
|
216
219
|
} catch (error) {
|
|
220
|
+
if (isSessionChangedError(error)) throw error;
|
|
217
221
|
if (__DEV__ && logger) {
|
|
218
222
|
logger('Failed to fetch device sessions after login', error);
|
|
219
223
|
}
|
|
@@ -231,6 +235,7 @@ export const useAuthOperations = ({
|
|
|
231
235
|
try {
|
|
232
236
|
await oxyServices.session.logout(sessionResponse.sessionId, sessionResponse.sessionId);
|
|
233
237
|
} catch (logoutError) {
|
|
238
|
+
if (isSessionChangedError(logoutError)) throw logoutError;
|
|
234
239
|
// Non-critical - continue to switch session even if logout fails
|
|
235
240
|
if (__DEV__ && logger) {
|
|
236
241
|
logger('Failed to logout duplicate session, continuing with switch', logoutError);
|
|
@@ -289,6 +294,7 @@ export const useAuthOperations = ({
|
|
|
289
294
|
*/
|
|
290
295
|
const signIn = useCallback(
|
|
291
296
|
async (publicKey: string, deviceName?: string): Promise<User> => {
|
|
297
|
+
let superseded = false;
|
|
292
298
|
runtime.batch(() => {
|
|
293
299
|
runtime.setLoading(true);
|
|
294
300
|
runtime.setError(null);
|
|
@@ -297,6 +303,10 @@ export const useAuthOperations = ({
|
|
|
297
303
|
try {
|
|
298
304
|
return await performSignIn(publicKey);
|
|
299
305
|
} catch (error) {
|
|
306
|
+
if (isSessionChangedError(error)) {
|
|
307
|
+
superseded = true;
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
300
310
|
const message = handleAuthError(error, {
|
|
301
311
|
defaultMessage: 'Sign in failed',
|
|
302
312
|
code: LOGIN_ERROR_CODE,
|
|
@@ -307,7 +317,7 @@ export const useAuthOperations = ({
|
|
|
307
317
|
runtime.setError(message);
|
|
308
318
|
throw error;
|
|
309
319
|
} finally {
|
|
310
|
-
runtime.setLoading(false);
|
|
320
|
+
if (!superseded) runtime.setLoading(false);
|
|
311
321
|
}
|
|
312
322
|
},
|
|
313
323
|
[runtime, performSignIn, onError, logger],
|
|
@@ -329,7 +339,7 @@ export const useAuthOperations = ({
|
|
|
329
339
|
revokeSelf: (sessionId) => oxyServices.session.logout(sessionId),
|
|
330
340
|
clearSessionState,
|
|
331
341
|
});
|
|
332
|
-
if (result.status === 'failed') {
|
|
342
|
+
if (result.status === 'failed' && !isSessionChangedError(result.error)) {
|
|
333
343
|
handleAuthError(result.error, {
|
|
334
344
|
defaultMessage: 'Logout failed',
|
|
335
345
|
code: LOGOUT_ERROR_CODE,
|
|
@@ -378,6 +388,7 @@ export const useAuthOperations = ({
|
|
|
378
388
|
}
|
|
379
389
|
return { status: 'signed-out' };
|
|
380
390
|
} catch (error) {
|
|
391
|
+
if (isSessionChangedError(error)) return { status: 'failed', error };
|
|
381
392
|
const isInvalid = isInvalidSessionError(error);
|
|
382
393
|
|
|
383
394
|
// Compare the RESOLVED target, not the raw optional parameter: every
|
|
@@ -459,6 +470,7 @@ export const useAuthOperations = ({
|
|
|
459
470
|
await clearPersistedAuthSafe(store, logger);
|
|
460
471
|
await clearSessionState();
|
|
461
472
|
} catch (error) {
|
|
473
|
+
if (isSessionChangedError(error)) throw error;
|
|
462
474
|
if (isInvalidSessionError(error)) {
|
|
463
475
|
// An already-invalid bearer means the sessions this call would have
|
|
464
476
|
// revoked are ALREADY gone — the sign-out happened, it just happened
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSessionChangedError } from '../utils/sessionChangedError';
|
|
2
|
+
|
|
1
3
|
/** Local display hint when a session lacks explicit `expiresAt` (7 days). */
|
|
2
4
|
export const DEFAULT_SESSION_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
3
5
|
|
|
@@ -24,5 +26,6 @@ export function getHttpStatus(error: unknown): number | undefined {
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export function isUnauthorizedStatus(error: unknown): boolean {
|
|
29
|
+
if (isSessionChangedError(error)) return false;
|
|
27
30
|
return getHttpStatus(error) === 401;
|
|
28
31
|
}
|
|
@@ -20,6 +20,7 @@ import type { FollowStatus } from '@oxy.so/contracts';
|
|
|
20
20
|
import { useOxy } from '../context/oxyRuntimeContext';
|
|
21
21
|
import {
|
|
22
22
|
isCompleteFollowStatus,
|
|
23
|
+
getFollowTargetGeneration,
|
|
23
24
|
isFollowedGlobally,
|
|
24
25
|
UNKNOWN_FOLLOW_STATUS,
|
|
25
26
|
useFollowTargetStore,
|
|
@@ -92,11 +93,14 @@ export function useFollowTarget(
|
|
|
92
93
|
// that window 401s and would leave the button stuck reporting an error the
|
|
93
94
|
// user cannot act on.
|
|
94
95
|
if (!targetId || !canUsePrivateApi) return;
|
|
96
|
+
const generation = getFollowTargetGeneration();
|
|
95
97
|
try {
|
|
96
98
|
const next = await oxyServices.follows.targetStatus(targetId);
|
|
99
|
+
if (generation !== getFollowTargetGeneration()) return;
|
|
97
100
|
useFollowTargetStore.getState().setStatus(targetId, next);
|
|
98
101
|
useFollowTargetStore.getState().setError(targetId, undefined);
|
|
99
102
|
} catch (e) {
|
|
103
|
+
if (generation !== getFollowTargetGeneration()) return;
|
|
100
104
|
const store = useFollowTargetStore.getState();
|
|
101
105
|
// Leave `isUnknown` false so the button stays interactive and can retry.
|
|
102
106
|
// A failed read is not the same as "never asked" — disabling forever would
|
|
@@ -127,6 +131,7 @@ export function useFollowTarget(
|
|
|
127
131
|
failure: FollowActionErrorCode,
|
|
128
132
|
): Promise<boolean> => {
|
|
129
133
|
if (!targetId) return false;
|
|
134
|
+
const generation = getFollowTargetGeneration();
|
|
130
135
|
const store = useFollowTargetStore.getState();
|
|
131
136
|
const previous = store.statuses[targetId];
|
|
132
137
|
store.setStatus(targetId, optimistic);
|
|
@@ -134,9 +139,11 @@ export function useFollowTarget(
|
|
|
134
139
|
store.setError(targetId, undefined);
|
|
135
140
|
try {
|
|
136
141
|
const settled = await run();
|
|
142
|
+
if (generation !== getFollowTargetGeneration()) return false;
|
|
137
143
|
if (settled) useFollowTargetStore.getState().setStatus(targetId, settled);
|
|
138
144
|
return true;
|
|
139
145
|
} catch (e) {
|
|
146
|
+
if (generation !== getFollowTargetGeneration()) return false;
|
|
140
147
|
const s = useFollowTargetStore.getState();
|
|
141
148
|
// Back to what was true, not to a guess. Clearing the entry instead
|
|
142
149
|
// would make the next render ask again and flash the wrong state.
|
|
@@ -144,7 +151,9 @@ export function useFollowTarget(
|
|
|
144
151
|
s.setError(targetId, toFollowErrorCode(e, failure));
|
|
145
152
|
return false;
|
|
146
153
|
} finally {
|
|
147
|
-
|
|
154
|
+
if (generation === getFollowTargetGeneration()) {
|
|
155
|
+
useFollowTargetStore.getState().setPending(targetId, false);
|
|
156
|
+
}
|
|
148
157
|
}
|
|
149
158
|
},
|
|
150
159
|
[targetId],
|
|
@@ -6,6 +6,7 @@ import { getStorageKeys, type StorageInterface } from '../utils/storageHelpers';
|
|
|
6
6
|
import { handleAuthError, isInvalidSessionError } from '../utils/errorHandlers';
|
|
7
7
|
import type { OxyServices } from '@oxy.so/core';
|
|
8
8
|
import type { QueryClient } from '@tanstack/react-query';
|
|
9
|
+
import { isSessionChangedError } from '../utils/sessionChangedError';
|
|
9
10
|
import { clearQueryCache } from './queryClient';
|
|
10
11
|
import { isWebBrowser } from '../utils/isWebBrowser';
|
|
11
12
|
import { resetSessionScopedStores } from '../stores/resetSessionScopedStores';
|
|
@@ -188,6 +189,7 @@ export const useSessionManagement = ({
|
|
|
188
189
|
});
|
|
189
190
|
activeRuntime.mergeSessions(deviceSessions, { merge: true });
|
|
190
191
|
} catch (error) {
|
|
192
|
+
if (isSessionChangedError(error)) throw error;
|
|
191
193
|
if (isDev()) {
|
|
192
194
|
console.warn('Failed to synchronize sessions after switch:', error);
|
|
193
195
|
}
|
|
@@ -195,6 +197,7 @@ export const useSessionManagement = ({
|
|
|
195
197
|
|
|
196
198
|
return user;
|
|
197
199
|
} catch (error) {
|
|
200
|
+
if (isSessionChangedError(error)) throw error;
|
|
198
201
|
const invalidSession = isInvalidSessionError(error);
|
|
199
202
|
|
|
200
203
|
if (invalidSession) {
|
package/src/ui/index.ts
CHANGED
|
@@ -24,6 +24,9 @@ import '../registerPlatform';
|
|
|
24
24
|
// in the first-paint bundle of every app that imports anything from `ui`
|
|
25
25
|
// (Allo: +340 KB gzipped).
|
|
26
26
|
|
|
27
|
+
// Shared organization form validation does not grant creation authority.
|
|
28
|
+
export { parseOrganizationDraft } from './utils/organizationDraft';
|
|
29
|
+
|
|
27
30
|
// Components
|
|
28
31
|
export { default as OxyProvider } from './components/OxyProvider';
|
|
29
32
|
export { default as OxySignInButton } from './components/OxySignInButton';
|
|
@@ -190,3 +190,65 @@ describe('resetFollowState', () => {
|
|
|
190
190
|
expect(useFollowStore.getState().followingUsers).toEqual({});
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
|
+
|
|
194
|
+
describe('follow operations across account resets', () => {
|
|
195
|
+
it.each(['resolve', 'reject'] as const)(
|
|
196
|
+
'does not reconcile a previous account toggle when it %s',
|
|
197
|
+
async (outcome) => {
|
|
198
|
+
const { mock, services } = makeServices();
|
|
199
|
+
let resolve!: (result: FollowMutationResult) => void;
|
|
200
|
+
let reject!: (error: Error) => void;
|
|
201
|
+
mock.follows.follow.mockReturnValueOnce(
|
|
202
|
+
new Promise((res, rej) => {
|
|
203
|
+
resolve = res;
|
|
204
|
+
reject = rej;
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
const pending = useFollowStore.getState().toggleFollowUser('u1', services, false);
|
|
208
|
+
useFollowStore.getState().resetFollowState();
|
|
209
|
+
useFollowStore.setState({
|
|
210
|
+
followingUsers: { u1: true },
|
|
211
|
+
loadingUsers: { u1: true },
|
|
212
|
+
errors: { u1: 'read_failed' },
|
|
213
|
+
});
|
|
214
|
+
if (outcome === 'resolve')
|
|
215
|
+
resolve({ message: 'ok', counts: { followers: 10, following: 3 } });
|
|
216
|
+
else reject(new Error('old request failed'));
|
|
217
|
+
expect(await pending).toBe(false);
|
|
218
|
+
const state = useFollowStore.getState();
|
|
219
|
+
expect(state.followingUsers.u1).toBe(true);
|
|
220
|
+
expect(state.loadingUsers.u1).toBe(true);
|
|
221
|
+
expect(state.errors.u1).toBe('read_failed');
|
|
222
|
+
expect(state.followerCounts.u1).toBeUndefined();
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
it.each(['followMany', 'unfollowMany'] as const)(
|
|
227
|
+
'does not reconcile a previous account %s',
|
|
228
|
+
async (method) => {
|
|
229
|
+
const { services } = makeServices();
|
|
230
|
+
let resolve!: (result: unknown) => void;
|
|
231
|
+
const response = new Promise((res) => {
|
|
232
|
+
resolve = res;
|
|
233
|
+
});
|
|
234
|
+
Object.assign(services.follows, { [method]: jest.fn(() => response) });
|
|
235
|
+
const store = useFollowStore.getState();
|
|
236
|
+
const pending =
|
|
237
|
+
method === 'followMany'
|
|
238
|
+
? store.followManyUsers(['u1'], services)
|
|
239
|
+
: store.unfollowManyUsers(['u1'], services);
|
|
240
|
+
store.resetFollowState();
|
|
241
|
+
useFollowStore.setState({
|
|
242
|
+
followingUsers: { u1: true },
|
|
243
|
+
loadingUsers: { u1: true },
|
|
244
|
+
errors: { u1: 'read_failed' },
|
|
245
|
+
});
|
|
246
|
+
resolve({ results: [] });
|
|
247
|
+
await expect(pending).rejects.toMatchObject({ code: 'AUTH_SESSION_CHANGED' });
|
|
248
|
+
const state = useFollowStore.getState();
|
|
249
|
+
expect(state.followingUsers.u1).toBe(true);
|
|
250
|
+
expect(state.loadingUsers.u1).toBe(true);
|
|
251
|
+
expect(state.errors.u1).toBe('read_failed');
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
FollowMutationResult,
|
|
7
7
|
} from '@oxy.so/core';
|
|
8
8
|
import { type FollowErrorCode, toFollowErrorCode } from '../utils/followErrors';
|
|
9
|
+
import { createSessionChangedError } from '../utils/sessionChangedError';
|
|
9
10
|
|
|
10
11
|
interface FollowState {
|
|
11
12
|
// Tri-state follow map: a MISSING key means UNKNOWN (status not yet resolved),
|
|
@@ -206,6 +207,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
206
207
|
});
|
|
207
208
|
})
|
|
208
209
|
.finally(() => {
|
|
210
|
+
if (generation !== batchGeneration) return;
|
|
209
211
|
for (const id of ids) inFlightIds.delete(id);
|
|
210
212
|
});
|
|
211
213
|
});
|
|
@@ -215,6 +217,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
215
217
|
oxyServices: OxyServices,
|
|
216
218
|
isCurrentlyFollowing: boolean,
|
|
217
219
|
) => {
|
|
220
|
+
const generation = batchGeneration;
|
|
218
221
|
// Snapshot the prior value for rollback. `undefined` = was UNKNOWN.
|
|
219
222
|
const priorState = get().followingUsers;
|
|
220
223
|
const hadPrevious = hasKey(priorState, userId);
|
|
@@ -233,6 +236,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
233
236
|
const response: FollowMutationResult = isCurrentlyFollowing
|
|
234
237
|
? await oxyServices.follows.unfollow(userId)
|
|
235
238
|
: await oxyServices.follows.follow(userId);
|
|
239
|
+
if (generation !== batchGeneration) return false;
|
|
236
240
|
|
|
237
241
|
// Reconcile: confirm the optimistic value is authoritative, clear loading.
|
|
238
242
|
set((state) => ({
|
|
@@ -262,6 +266,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
262
266
|
}
|
|
263
267
|
return true;
|
|
264
268
|
} catch (error: unknown) {
|
|
269
|
+
if (generation !== batchGeneration) return false;
|
|
265
270
|
// Roll back to the prior value (or back to UNKNOWN if there was none).
|
|
266
271
|
set((state) => {
|
|
267
272
|
const followingUsers = { ...state.followingUsers };
|
|
@@ -286,6 +291,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
286
291
|
userIds: string[],
|
|
287
292
|
oxyServices: OxyServices,
|
|
288
293
|
): Promise<BulkFollowResult> => {
|
|
294
|
+
const generation = batchGeneration;
|
|
289
295
|
// Snapshot prior values for rollback (tri-state: undefined = unknown).
|
|
290
296
|
const prior = get().followingUsers;
|
|
291
297
|
const previous = new Map<string, boolean | undefined>();
|
|
@@ -308,6 +314,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
308
314
|
|
|
309
315
|
try {
|
|
310
316
|
const result = await oxyServices.follows.followMany(userIds);
|
|
317
|
+
if (generation !== batchGeneration) throw createSessionChangedError();
|
|
311
318
|
set((state) => {
|
|
312
319
|
const followingUsers = { ...state.followingUsers };
|
|
313
320
|
const loadingUsers = { ...state.loadingUsers };
|
|
@@ -334,6 +341,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
334
341
|
});
|
|
335
342
|
return result;
|
|
336
343
|
} catch (error: unknown) {
|
|
344
|
+
if (generation !== batchGeneration) throw createSessionChangedError();
|
|
337
345
|
const code = toFollowErrorCode(error, 'update_failed');
|
|
338
346
|
// Whole batch failed — roll ALL targets back to their prior values.
|
|
339
347
|
set((state) => {
|
|
@@ -359,6 +367,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
359
367
|
userIds: string[],
|
|
360
368
|
oxyServices: OxyServices,
|
|
361
369
|
): Promise<BulkUnfollowResult> => {
|
|
370
|
+
const generation = batchGeneration;
|
|
362
371
|
// Snapshot prior values for rollback (tri-state: undefined = unknown).
|
|
363
372
|
const prior = get().followingUsers;
|
|
364
373
|
const previous = new Map<string, boolean | undefined>();
|
|
@@ -381,6 +390,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
381
390
|
|
|
382
391
|
try {
|
|
383
392
|
const result = await oxyServices.follows.unfollowMany(userIds);
|
|
393
|
+
if (generation !== batchGeneration) throw createSessionChangedError();
|
|
384
394
|
set((state) => {
|
|
385
395
|
const followingUsers = { ...state.followingUsers };
|
|
386
396
|
const loadingUsers = { ...state.loadingUsers };
|
|
@@ -407,6 +417,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
407
417
|
});
|
|
408
418
|
return result;
|
|
409
419
|
} catch (error: unknown) {
|
|
420
|
+
if (generation !== batchGeneration) throw createSessionChangedError();
|
|
410
421
|
const code = toFollowErrorCode(error, 'update_failed');
|
|
411
422
|
// Whole batch failed — roll ALL targets back to their prior values.
|
|
412
423
|
set((state) => {
|
|
@@ -454,11 +465,13 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
454
465
|
});
|
|
455
466
|
},
|
|
456
467
|
fetchUserCounts: async (userId: string, oxyServices: OxyServices) => {
|
|
468
|
+
const generation = batchGeneration;
|
|
457
469
|
set((state) => ({
|
|
458
470
|
loadingCounts: { ...state.loadingCounts, [userId]: true },
|
|
459
471
|
}));
|
|
460
472
|
try {
|
|
461
473
|
const user = await oxyServices.users.get(userId);
|
|
474
|
+
if (generation !== batchGeneration) return;
|
|
462
475
|
if (user?._count) {
|
|
463
476
|
set((state) => ({
|
|
464
477
|
followerCounts: {
|
|
@@ -477,6 +490,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
|
|
|
477
490
|
}));
|
|
478
491
|
}
|
|
479
492
|
} catch (error: unknown) {
|
|
493
|
+
if (generation !== batchGeneration) return;
|
|
480
494
|
set((state) => ({
|
|
481
495
|
loadingCounts: { ...state.loadingCounts, [userId]: false },
|
|
482
496
|
}));
|
|
@@ -29,6 +29,13 @@ import type { FollowErrorCode } from '../utils/followErrors';
|
|
|
29
29
|
/** The status of one target, or `undefined` when it has never been read. */
|
|
30
30
|
type StatusMap = Record<string, FollowStatus | undefined>;
|
|
31
31
|
|
|
32
|
+
let followTargetGeneration = 0;
|
|
33
|
+
|
|
34
|
+
/** The generation changes whenever account-scoped follow state is reset. */
|
|
35
|
+
export function getFollowTargetGeneration(): number {
|
|
36
|
+
return followTargetGeneration;
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
interface FollowTargetState {
|
|
33
40
|
statuses: StatusMap;
|
|
34
41
|
/** In-flight writes, per target. Reads do not set this — only mutations do. */
|
|
@@ -80,7 +87,10 @@ export const useFollowTargetStore = create<FollowTargetState>((set) => ({
|
|
|
80
87
|
// account switch the whole map is wrong, not stale — the next user's follows
|
|
81
88
|
// are a different set entirely, and showing one user their previous
|
|
82
89
|
// account's follow state is a privacy failure rather than a rendering one.
|
|
83
|
-
reset: () =>
|
|
90
|
+
reset: () => {
|
|
91
|
+
followTargetGeneration += 1;
|
|
92
|
+
set({ statuses: {}, pending: {}, errors: {} });
|
|
93
|
+
},
|
|
84
94
|
}));
|
|
85
95
|
|
|
86
96
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ApiError } from '@oxy.so/core';
|
|
2
|
+
import { isSessionChangedError } from './sessionChangedError';
|
|
2
3
|
|
|
3
4
|
type ErrorWithMessage = {
|
|
4
5
|
message?: string;
|
|
@@ -52,6 +53,7 @@ const getResponseStatus = (error: unknown): number | undefined => {
|
|
|
52
53
|
* This centralizes 401 detection across different fetch clients.
|
|
53
54
|
*/
|
|
54
55
|
export const isInvalidSessionError = (error: unknown): boolean => {
|
|
56
|
+
if (isSessionChangedError(error)) return false;
|
|
55
57
|
const status = getResponseStatus(error);
|
|
56
58
|
if (status === 401) {
|
|
57
59
|
return true;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSessionChangedError } from './sessionChangedError';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* What a failed follow read or write leaves behind: a stable CODE, never text.
|
|
3
5
|
*
|
|
@@ -43,6 +45,7 @@ const OFFLINE_CODES = new Set(['NETWORK_ERROR', 'CONNECTION_FAILED', 'TIMEOUT'])
|
|
|
43
45
|
* Anything the cause does not explain falls back to the caller's `action`.
|
|
44
46
|
*/
|
|
45
47
|
export function toFollowErrorCode(error: unknown, action: FollowActionErrorCode): FollowErrorCode {
|
|
48
|
+
if (isSessionChangedError(error)) return action;
|
|
46
49
|
const { status, code } = (error && typeof error === 'object' ? error : {}) as {
|
|
47
50
|
status?: unknown;
|
|
48
51
|
code?: unknown;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { checkContract, createOrganizationCheck } from '@oxy.so/contracts/client';
|
|
2
|
+
import type { CreateOrganization } from '@oxy.so/contracts';
|
|
3
|
+
|
|
4
|
+
/** Normalize and validate form fields; callers must separately authorize creation. */
|
|
5
|
+
export function parseOrganizationDraft(input: {
|
|
6
|
+
name: string;
|
|
7
|
+
organizationType: CreateOrganization['organizationType'];
|
|
8
|
+
websiteUrl: string;
|
|
9
|
+
countryCode: string;
|
|
10
|
+
}): CreateOrganization | null {
|
|
11
|
+
return checkContract(createOrganizationCheck, {
|
|
12
|
+
name: input.name.trim(),
|
|
13
|
+
organizationType: input.organizationType,
|
|
14
|
+
websiteUrl: input.websiteUrl.trim() || null,
|
|
15
|
+
countryCode: input.countryCode.trim().toUpperCase() || null,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A locally superseded request is not a server rejection of the new session. */
|
|
2
|
+
export function isSessionChangedError(error: unknown): boolean {
|
|
3
|
+
return (
|
|
4
|
+
!!error && typeof error === 'object' && 'code' in error && error.code === 'AUTH_SESSION_CHANGED'
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** A locally superseded operation must not be mistaken for a server 401. */
|
|
9
|
+
export function createSessionChangedError(): Error & { code: string; status: number } {
|
|
10
|
+
return Object.assign(new Error('The request session changed'), {
|
|
11
|
+
code: 'AUTH_SESSION_CHANGED',
|
|
12
|
+
status: 401,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ClientSession } from '@oxy.so/core';
|
|
2
|
+
import { isSessionChangedError } from './sessionChangedError';
|
|
2
3
|
|
|
3
4
|
interface DeviceSession {
|
|
4
5
|
sessionId: string;
|
|
@@ -73,6 +74,7 @@ export const fetchSessionsWithFallback = async (
|
|
|
73
74
|
const deviceSessions = await oxyServices.devices.sessions(sessionId);
|
|
74
75
|
return mapSessionsToClient(deviceSessions, fallbackDeviceId, fallbackUserId);
|
|
75
76
|
} catch (error) {
|
|
77
|
+
if (isSessionChangedError(error)) throw error;
|
|
76
78
|
if (__DEV__ && logger) {
|
|
77
79
|
logger('Failed to get device sessions, falling back to user sessions', error);
|
|
78
80
|
}
|