@sanity/sdk 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -42,6 +42,7 @@
42
42
  "browserslist": "extends @sanity/browserslist-config",
43
43
  "prettier": "@sanity/prettier-config",
44
44
  "dependencies": {
45
+ "@sanity/bifur-client": "^0.4.1",
45
46
  "@sanity/client": "^7.2.1",
46
47
  "@sanity/comlink": "^3.0.4",
47
48
  "@sanity/diff-match-patch": "^3.2.0",
@@ -58,7 +59,7 @@
58
59
  },
59
60
  "devDependencies": {
60
61
  "@sanity/browserslist-config": "^1.0.5",
61
- "@sanity/pkg-utils": "^7.2.2",
62
+ "@sanity/pkg-utils": "^7.9.6",
62
63
  "@sanity/prettier-config": "^1.0.3",
63
64
  "@types/lodash-es": "^4.17.12",
64
65
  "@vitest/coverage-v8": "3.1.2",
@@ -104,6 +104,15 @@ export {type JsonMatch} from '../document/patchOperations'
104
104
  export {type DocumentPermissionsResult, type PermissionDeniedReason} from '../document/permissions'
105
105
  export type {FavoriteStatusResponse} from '../favorites/favorites'
106
106
  export {getFavoritesState, resolveFavoritesState} from '../favorites/favorites'
107
+ export {getPresence} from '../presence/presenceStore'
108
+ export type {
109
+ DisconnectEvent,
110
+ PresenceLocation,
111
+ RollCallEvent,
112
+ StateEvent,
113
+ TransportEvent,
114
+ UserPresence,
115
+ } from '../presence/types'
107
116
  export {getPreviewState, type GetPreviewStateOptions} from '../preview/getPreviewState'
108
117
  export type {PreviewStoreState, PreviewValue, ValuePending} from '../preview/previewStore'
109
118
  export {resolvePreview, type ResolvePreviewOptions} from '../preview/resolvePreview'
@@ -127,15 +136,27 @@ export {createSanityInstance, type SanityInstance} from '../store/createSanityIn
127
136
  export {type Selector, type StateSource} from '../store/createStateSourceAction'
128
137
  export {getUsersKey, parseUsersKey} from '../users/reducers'
129
138
  export {
139
+ type GetUserOptions,
130
140
  type GetUsersOptions,
131
141
  type Membership,
142
+ type ResolveUserOptions,
132
143
  type ResolveUsersOptions,
133
144
  type SanityUser,
145
+ type SanityUserResponse,
134
146
  type UserProfile,
147
+ type UsersGroupState,
148
+ type UsersStoreState,
135
149
  } from '../users/types'
136
- export {getUsersState, loadMoreUsers, resolveUsers} from '../users/usersStore'
150
+ export {
151
+ getUsersState,
152
+ getUserState,
153
+ loadMoreUsers,
154
+ resolveUser,
155
+ resolveUsers,
156
+ } from '../users/usersStore'
137
157
  export {type FetcherStore, type FetcherStoreState} from '../utils/createFetcherStore'
138
158
  export {createGroqSearchFilter} from '../utils/createGroqSearchFilter'
159
+ export {defineIntent, type Intent, type IntentFilter} from '../utils/defineIntent'
139
160
  export {CORE_SDK_VERSION} from '../version'
140
161
  export {
141
162
  getIndexForKey,
@@ -35,6 +35,7 @@ export type LoggedInAuthState = {
35
35
  type: AuthStateType.LOGGED_IN
36
36
  token: string
37
37
  currentUser: CurrentUser | null
38
+ lastTokenRefresh?: number
38
39
  }
39
40
 
40
41
  /**
@@ -8,7 +8,12 @@ import {createStoreState} from '../store/createStoreState'
8
8
  import {AuthStateType} from './authStateType'
9
9
  import {type AuthState, authStore} from './authStore'
10
10
  // Import only the public function
11
- import {refreshStampedToken} from './refreshStampedToken'
11
+ import {
12
+ getLastRefreshTime,
13
+ getNextRefreshDelay,
14
+ refreshStampedToken,
15
+ setLastRefreshTime,
16
+ } from './refreshStampedToken'
12
17
 
13
18
  // Type definitions for Web Locks (can be kept if needed for context)
14
19
  // ... (Lock, LockOptions, LockGrantedCallback types)
@@ -16,6 +21,7 @@ import {refreshStampedToken} from './refreshStampedToken'
16
21
  describe('refreshStampedToken', () => {
17
22
  let mockStorage: Storage
18
23
  let originalNavigator: typeof navigator // Restored
24
+ let originalDocument: Document
19
25
  let subscriptions: Subscription[]
20
26
  // mockLocksRequest removed
21
27
 
@@ -25,6 +31,19 @@ describe('refreshStampedToken', () => {
25
31
  vi.useFakeTimers()
26
32
 
27
33
  originalNavigator = global.navigator // Restore original navigator setup
34
+
35
+ // Mock document for visibility API
36
+ originalDocument = global.document
37
+ Object.defineProperty(global, 'document', {
38
+ value: {
39
+ visibilityState: 'visible',
40
+ addEventListener: vi.fn(),
41
+ removeEventListener: vi.fn(),
42
+ },
43
+ writable: true,
44
+ configurable: true,
45
+ })
46
+
28
47
  mockStorage = {
29
48
  getItem: vi.fn(),
30
49
  setItem: vi.fn(),
@@ -76,6 +95,11 @@ describe('refreshStampedToken', () => {
76
95
  value: originalNavigator,
77
96
  writable: true,
78
97
  })
98
+ // Restore original document
99
+ Object.defineProperty(global, 'document', {
100
+ value: originalDocument,
101
+ writable: true,
102
+ })
79
103
  // Restore real timers
80
104
  try {
81
105
  await vi.runAllTimersAsync() // Attempt to flush cleanly
@@ -122,6 +146,49 @@ describe('refreshStampedToken', () => {
122
146
  const locksRequest = navigator.locks.request as ReturnType<typeof vi.fn>
123
147
  expect(locksRequest).not.toHaveBeenCalled()
124
148
  })
149
+
150
+ it('does not refresh when tab is not visible', async () => {
151
+ // Set visibility to hidden
152
+ Object.defineProperty(global, 'document', {
153
+ value: {
154
+ visibilityState: 'hidden',
155
+ addEventListener: vi.fn(),
156
+ removeEventListener: vi.fn(),
157
+ },
158
+ writable: true,
159
+ configurable: true,
160
+ })
161
+
162
+ const mockClient = {
163
+ observable: {request: vi.fn(() => of({token: 'sk-refreshed-token-st123'}))},
164
+ }
165
+ const mockClientFactory = vi.fn().mockReturnValue(mockClient)
166
+ const instance = createSanityInstance({
167
+ projectId: 'p',
168
+ dataset: 'd',
169
+ auth: {clientFactory: mockClientFactory, storageArea: mockStorage},
170
+ })
171
+ const initialState = authStore.getInitialState(instance)
172
+ initialState.authState = {
173
+ type: AuthStateType.LOGGED_IN,
174
+ token: 'sk-initial-token-st123',
175
+ currentUser: null,
176
+ }
177
+ initialState.dashboardContext = {mode: 'test'}
178
+ const state = createStoreState(initialState)
179
+
180
+ const subscription = refreshStampedToken({state, instance})
181
+ subscriptions.push(subscription)
182
+
183
+ await vi.advanceTimersToNextTimerAsync()
184
+
185
+ // Verify that no refresh occurred
186
+ expect(mockClient.observable.request).not.toHaveBeenCalled()
187
+ const finalAuthState = state.get().authState
188
+ if (finalAuthState.type === AuthStateType.LOGGED_IN) {
189
+ expect(finalAuthState.token).toBe('sk-initial-token-st123')
190
+ }
191
+ })
125
192
  })
126
193
 
127
194
  describe('non-dashboard context', () => {
@@ -150,11 +217,19 @@ describe('refreshStampedToken', () => {
150
217
  subscriptions.push(subscription!)
151
218
  }).not.toThrow()
152
219
 
153
- // Avoid advancing timers here, as it would trigger the infinite loop
154
- // await vi.advanceTimersByTimeAsync(0)
155
-
156
- // No assertions about navigator.locks.request call
157
- // No assertions about final token state (due to infinite loop in source)
220
+ // DO NOT advance timers or yield here - focus on immediate observable logic
221
+ // We cannot reliably test that failingLocksRequest is called due to async/timer issues,
222
+ // but we *can* test the consequence of it resolving to false.
223
+
224
+ // VERIFY THE OUTCOME:
225
+ // Check client request was NOT made (because filter(hasLock => hasLock) receives false)
226
+ expect(mockClient.observable.request).not.toHaveBeenCalled()
227
+ // Check state remains unchanged
228
+ const finalAuthState = state.get().authState
229
+ expect(finalAuthState.type).toBe(AuthStateType.LOGGED_IN)
230
+ if (finalAuthState.type === AuthStateType.LOGGED_IN) {
231
+ expect(finalAuthState.token).toBe('sk-initial-token-st123')
232
+ }
158
233
  })
159
234
 
160
235
  it('skips refresh if lock request returns false', async () => {
@@ -209,6 +284,61 @@ describe('refreshStampedToken', () => {
209
284
  })
210
285
  })
211
286
 
287
+ describe('unsupported environments', () => {
288
+ it('falls back to immediate refresh if Web Locks API is not supported', async () => {
289
+ // Temporarily remove navigator.locks for this test
290
+ const originalLocks = navigator.locks
291
+ Object.defineProperty(global.navigator, 'locks', {
292
+ value: undefined,
293
+ writable: true,
294
+ })
295
+
296
+ try {
297
+ const mockClient = {
298
+ observable: {request: vi.fn(() => of({token: 'sk-refreshed-immediately-st123'}))},
299
+ }
300
+ const mockClientFactory = vi.fn().mockReturnValue(mockClient)
301
+ const instance = createSanityInstance({
302
+ projectId: 'p',
303
+ dataset: 'd',
304
+ auth: {clientFactory: mockClientFactory, storageArea: mockStorage},
305
+ })
306
+ const initialState = authStore.getInitialState(instance)
307
+ initialState.authState = {
308
+ type: AuthStateType.LOGGED_IN,
309
+ token: 'sk-initial-token-st123',
310
+ currentUser: null,
311
+ }
312
+ const state = createStoreState(initialState)
313
+
314
+ const subscription = refreshStampedToken({state, instance})
315
+ subscriptions.push(subscription)
316
+
317
+ // Advance timers to allow the async `performRefresh` to execute
318
+ await vi.advanceTimersToNextTimerAsync()
319
+
320
+ // Verify the refresh was performed and state was updated
321
+ expect(mockClient.observable.request).toHaveBeenCalled()
322
+ const finalAuthState = state.get().authState
323
+ expect(finalAuthState.type).toBe(AuthStateType.LOGGED_IN)
324
+ if (finalAuthState.type === AuthStateType.LOGGED_IN) {
325
+ expect(finalAuthState.token).toBe('sk-refreshed-immediately-st123')
326
+ }
327
+ // Verify token was set in storage
328
+ expect(mockStorage.setItem).toHaveBeenCalledWith(
329
+ initialState.options.storageKey,
330
+ JSON.stringify({token: 'sk-refreshed-immediately-st123'}),
331
+ )
332
+ } finally {
333
+ // Restore navigator.locks
334
+ Object.defineProperty(global.navigator, 'locks', {
335
+ value: originalLocks,
336
+ writable: true,
337
+ })
338
+ }
339
+ })
340
+ })
341
+
212
342
  // Restore other tests to their simpler form
213
343
  it('sets an error state when token refresh fails', async () => {
214
344
  const error = new Error('Refresh failed')
@@ -298,3 +428,92 @@ describe('refreshStampedToken', () => {
298
428
  }
299
429
  })
300
430
  })
431
+
432
+ describe('time-based refresh helpers', () => {
433
+ let mockStorage: Storage
434
+ const storageKey = 'my-test-key'
435
+
436
+ beforeEach(() => {
437
+ mockStorage = {
438
+ getItem: vi.fn(),
439
+ setItem: vi.fn(),
440
+ removeItem: vi.fn(),
441
+ clear: vi.fn(),
442
+ key: vi.fn(),
443
+ length: 0,
444
+ }
445
+ vi.useFakeTimers()
446
+ })
447
+
448
+ afterEach(() => {
449
+ vi.useRealTimers()
450
+ })
451
+
452
+ describe('getLastRefreshTime', () => {
453
+ it('returns 0 if storage is undefined', () => {
454
+ expect(getLastRefreshTime(undefined, storageKey)).toBe(0)
455
+ })
456
+
457
+ it('returns 0 if item is not in storage', () => {
458
+ vi.spyOn(mockStorage, 'getItem').mockReturnValue(null)
459
+ expect(getLastRefreshTime(mockStorage, storageKey)).toBe(0)
460
+ expect(mockStorage.getItem).toHaveBeenCalledWith(`${storageKey}_last_refresh`)
461
+ })
462
+
463
+ it('returns the parsed timestamp from storage', () => {
464
+ const now = Date.now()
465
+ vi.spyOn(mockStorage, 'getItem').mockReturnValue(now.toString())
466
+ expect(getLastRefreshTime(mockStorage, storageKey)).toBe(now)
467
+ })
468
+
469
+ it('returns 0 if stored data is malformed', () => {
470
+ vi.spyOn(mockStorage, 'getItem').mockReturnValue('not a number')
471
+ expect(getLastRefreshTime(mockStorage, storageKey)).toBe(0)
472
+ })
473
+
474
+ it('returns 0 on storage access error', () => {
475
+ vi.spyOn(mockStorage, 'getItem').mockImplementation(() => {
476
+ throw new Error('Storage access failed')
477
+ })
478
+ expect(getLastRefreshTime(mockStorage, storageKey)).toBe(0)
479
+ })
480
+ })
481
+
482
+ describe('setLastRefreshTime', () => {
483
+ it('sets the current timestamp in storage', () => {
484
+ const now = Date.now()
485
+ setLastRefreshTime(mockStorage, storageKey)
486
+ expect(mockStorage.setItem).toHaveBeenCalledWith(`${storageKey}_last_refresh`, now.toString())
487
+ })
488
+
489
+ it('does not throw on storage access error', () => {
490
+ vi.spyOn(mockStorage, 'setItem').mockImplementation(() => {
491
+ throw new Error('Storage access failed')
492
+ })
493
+ expect(() => setLastRefreshTime(mockStorage, storageKey)).not.toThrow()
494
+ })
495
+ })
496
+
497
+ describe('getNextRefreshDelay', () => {
498
+ const REFRESH_INTERVAL = 12 * 60 * 60 * 1000
499
+
500
+ it('returns 0 if last refresh time is not available', () => {
501
+ vi.spyOn(mockStorage, 'getItem').mockReturnValue(null)
502
+ expect(getNextRefreshDelay(mockStorage, storageKey)).toBe(0)
503
+ })
504
+
505
+ it('returns the remaining time until the next refresh', () => {
506
+ const lastRefresh = Date.now() - 10000 // 10 seconds ago
507
+ vi.spyOn(mockStorage, 'getItem').mockReturnValue(lastRefresh.toString())
508
+
509
+ const delay = getNextRefreshDelay(mockStorage, storageKey)
510
+ expect(delay).toBeCloseTo(REFRESH_INTERVAL - 10000, -2)
511
+ })
512
+
513
+ it('returns 0 if the refresh interval has passed', () => {
514
+ const lastRefresh = Date.now() - REFRESH_INTERVAL - 5000 // 5 seconds past due
515
+ vi.spyOn(mockStorage, 'getItem').mockReturnValue(lastRefresh.toString())
516
+ expect(getNextRefreshDelay(mockStorage, storageKey)).toBe(0)
517
+ })
518
+ })
519
+ })
@@ -20,16 +20,19 @@ import {type AuthState, type AuthStoreState} from './authStore'
20
20
  const REFRESH_INTERVAL = 12 * 60 * 60 * 1000 // 12 hours in milliseconds
21
21
  const LOCK_NAME = 'sanity-token-refresh-lock'
22
22
 
23
- function getLastRefreshTime(storageArea: Storage | undefined, storageKey: string): number {
23
+ /** @internal */
24
+ export function getLastRefreshTime(storageArea: Storage | undefined, storageKey: string): number {
24
25
  try {
25
26
  const data = storageArea?.getItem(`${storageKey}_last_refresh`)
26
- return data ? parseInt(data, 10) : 0
27
+ const parsed = data ? parseInt(data, 10) : 0
28
+ return isNaN(parsed) ? 0 : parsed
27
29
  } catch {
28
30
  return 0
29
31
  }
30
32
  }
31
33
 
32
- function setLastRefreshTime(storageArea: Storage | undefined, storageKey: string): void {
34
+ /** @internal */
35
+ export function setLastRefreshTime(storageArea: Storage | undefined, storageKey: string): void {
33
36
  try {
34
37
  storageArea?.setItem(`${storageKey}_last_refresh`, Date.now().toString())
35
38
  } catch {
@@ -37,7 +40,8 @@ function setLastRefreshTime(storageArea: Storage | undefined, storageKey: string
37
40
  }
38
41
  }
39
42
 
40
- function getNextRefreshDelay(storageArea: Storage | undefined, storageKey: string): number {
43
+ /** @internal */
44
+ export function getNextRefreshDelay(storageArea: Storage | undefined, storageKey: string): number {
41
45
  const lastRefresh = getLastRefreshTime(storageArea, storageKey)
42
46
  if (!lastRefresh) return 0
43
47
 
@@ -127,6 +131,12 @@ async function acquireTokenRefreshLock(
127
131
  }
128
132
  }
129
133
 
134
+ function shouldRefreshToken(lastRefresh: number | undefined): boolean {
135
+ if (!lastRefresh) return true
136
+ const timeSinceLastRefresh = Date.now() - lastRefresh
137
+ return timeSinceLastRefresh >= REFRESH_INTERVAL
138
+ }
139
+
130
140
  /**
131
141
  * @internal
132
142
  */
@@ -178,51 +188,106 @@ export const refreshStampedToken = ({state}: StoreContext<AuthStoreState>): Subs
178
188
  }
179
189
 
180
190
  if (storeState.dashboardContext) {
181
- return timer(REFRESH_INTERVAL, REFRESH_INTERVAL).pipe(
182
- // Check if still logged in before each refresh attempt in the timer
191
+ return new Observable<{token: string}>((subscriber) => {
192
+ const visibilityHandler = () => {
193
+ const currentState = state.get()
194
+ if (
195
+ document.visibilityState === 'visible' &&
196
+ currentState.authState.type === AuthStateType.LOGGED_IN &&
197
+ shouldRefreshToken(currentState.authState.lastTokenRefresh)
198
+ ) {
199
+ createTokenRefreshStream(
200
+ currentState.authState.token,
201
+ clientFactory,
202
+ apiHost,
203
+ ).subscribe({
204
+ next: (response) => {
205
+ state.set('setRefreshStampedToken', (prev) => ({
206
+ authState:
207
+ prev.authState.type === AuthStateType.LOGGED_IN
208
+ ? {
209
+ ...prev.authState,
210
+ token: response.token,
211
+ lastTokenRefresh: Date.now(),
212
+ }
213
+ : prev.authState,
214
+ }))
215
+ subscriber.next(response)
216
+ },
217
+ error: (error) => subscriber.error(error),
218
+ })
219
+ }
220
+ }
221
+
222
+ const timerSubscription = timer(REFRESH_INTERVAL, REFRESH_INTERVAL)
223
+ .pipe(
224
+ filter(() => document.visibilityState === 'visible'),
225
+ switchMap(() => {
226
+ const currentState = state.get().authState
227
+ if (currentState.type !== AuthStateType.LOGGED_IN) {
228
+ throw new Error('User logged out before refresh could complete')
229
+ }
230
+ return createTokenRefreshStream(currentState.token, clientFactory, apiHost)
231
+ }),
232
+ )
233
+ .subscribe({
234
+ next: (response) => {
235
+ state.set('setRefreshStampedToken', (prev) => ({
236
+ authState:
237
+ prev.authState.type === AuthStateType.LOGGED_IN
238
+ ? {
239
+ ...prev.authState,
240
+ token: response.token,
241
+ lastTokenRefresh: Date.now(),
242
+ }
243
+ : prev.authState,
244
+ }))
245
+ subscriber.next(response)
246
+ },
247
+ error: (error) => subscriber.error(error),
248
+ })
249
+
250
+ document.addEventListener('visibilitychange', visibilityHandler)
251
+
252
+ return () => {
253
+ document.removeEventListener('visibilitychange', visibilityHandler)
254
+ timerSubscription.unsubscribe()
255
+ }
256
+ }).pipe(
183
257
  takeWhile(() => state.get().authState.type === AuthStateType.LOGGED_IN),
184
- // Use switchMap here: if the timer ticks again, we *do* want the latest token request
185
- switchMap(() =>
186
- createTokenRefreshStream(storeState.authState.token, clientFactory, apiHost),
187
- ),
188
- // Map the successful response for the outer subscribe block
189
- map((response) => ({token: response.token})),
258
+ map((response: {token: string}) => ({token: response.token})),
190
259
  )
191
260
  }
192
261
 
193
- // If not in dashboard context, use lock-based refresh.
194
- // exhaustMap prevents this from running again if state changes while lock logic is active.
195
- // NOTE: Based on Web Locks API, this `from(acquireTokenRefreshLock(...))` observable
196
- // will likely NOT emit if the lock is successfully acquired, as the underlying promise
197
- // may never resolve due to the while(true) loop. This path needs verification.
262
+ // If not in dashboard context, use lock-based refresh
198
263
  return from(acquireTokenRefreshLock(performRefresh, storageArea, storageKey)).pipe(
199
264
  filter((hasLock) => hasLock),
200
- // If acquireTokenRefreshLock *does* somehow resolve true (e.g., locks unsupported),
201
- // emit the token that triggered this exhaustMap execution.
202
- map(() => ({token: storeState.authState.token})),
265
+ map(() => {
266
+ const currentState = state.get().authState
267
+ if (currentState.type !== AuthStateType.LOGGED_IN) {
268
+ throw new Error('User logged out before refresh could complete')
269
+ }
270
+ return {token: currentState.token} as const
271
+ }),
203
272
  )
204
273
  }),
205
274
  )
206
275
 
207
276
  return refreshToken$.subscribe({
208
- next: (response) => {
209
- // This block now primarily handles updates from the dashboard timer path,
210
- // or potentially from the lock path if acquireTokenRefreshLock resolves unexpectedly.
211
- // exhaustMap prevents this state update from causing an immediate loop.
277
+ next: (response: {token: string}) => {
212
278
  state.set('setRefreshStampedToken', (prev) => ({
213
279
  authState:
214
280
  prev.authState.type === AuthStateType.LOGGED_IN
215
- ? {...prev.authState, token: response.token}
281
+ ? {
282
+ ...prev.authState,
283
+ token: response.token,
284
+ lastTokenRefresh: Date.now(),
285
+ }
216
286
  : prev.authState,
217
287
  }))
218
288
  storageArea?.setItem(storageKey, JSON.stringify({token: response.token}))
219
289
  },
220
290
  error: (error) => {
221
- // Log errors from either refresh path
222
- // Consider how refresh failures should affect the overall auth state
223
- // E.g., maybe attempt retry, or eventually set state to ERROR if retries fail
224
- // For now, just log, avoiding immediate state change to ERROR
225
- // console.error('Token refresh failed:', error) // Removed to satisfy linter
226
291
  state.set('setRefreshStampedTokenError', {authState: {type: AuthStateType.ERROR, error}})
227
292
  },
228
293
  })