@sanity/sdk 0.0.0-alpha.21 → 0.0.0-alpha.23

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.
Files changed (127) hide show
  1. package/dist/index.d.ts +428 -325
  2. package/dist/index.js +1618 -1553
  3. package/dist/index.js.map +1 -1
  4. package/package.json +6 -7
  5. package/src/_exports/index.ts +31 -30
  6. package/src/auth/authStore.test.ts +149 -104
  7. package/src/auth/authStore.ts +51 -100
  8. package/src/auth/handleAuthCallback.test.ts +67 -34
  9. package/src/auth/handleAuthCallback.ts +8 -7
  10. package/src/auth/logout.test.ts +61 -29
  11. package/src/auth/logout.ts +26 -28
  12. package/src/auth/refreshStampedToken.test.ts +9 -9
  13. package/src/auth/refreshStampedToken.ts +62 -56
  14. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +5 -5
  15. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +45 -47
  16. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +4 -5
  17. package/src/auth/subscribeToStorageEventsAndSetToken.ts +22 -24
  18. package/src/client/clientStore.test.ts +131 -67
  19. package/src/client/clientStore.ts +117 -116
  20. package/src/comlink/controller/actions/destroyController.test.ts +38 -13
  21. package/src/comlink/controller/actions/destroyController.ts +11 -15
  22. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +56 -27
  23. package/src/comlink/controller/actions/getOrCreateChannel.ts +37 -35
  24. package/src/comlink/controller/actions/getOrCreateController.test.ts +27 -16
  25. package/src/comlink/controller/actions/getOrCreateController.ts +23 -22
  26. package/src/comlink/controller/actions/releaseChannel.test.ts +37 -13
  27. package/src/comlink/controller/actions/releaseChannel.ts +22 -21
  28. package/src/comlink/controller/comlinkControllerStore.test.ts +65 -36
  29. package/src/comlink/controller/comlinkControllerStore.ts +44 -5
  30. package/src/comlink/node/actions/getOrCreateNode.test.ts +31 -15
  31. package/src/comlink/node/actions/getOrCreateNode.ts +30 -29
  32. package/src/comlink/node/actions/releaseNode.test.ts +75 -55
  33. package/src/comlink/node/actions/releaseNode.ts +19 -21
  34. package/src/comlink/node/comlinkNodeStore.test.ts +6 -11
  35. package/src/comlink/node/comlinkNodeStore.ts +22 -5
  36. package/src/config/authConfig.ts +79 -0
  37. package/src/config/sanityConfig.ts +48 -0
  38. package/src/datasets/datasets.test.ts +2 -2
  39. package/src/datasets/datasets.ts +18 -5
  40. package/src/document/actions.test.ts +22 -10
  41. package/src/document/actions.ts +44 -56
  42. package/src/document/applyDocumentActions.test.ts +96 -36
  43. package/src/document/applyDocumentActions.ts +140 -99
  44. package/src/document/documentStore.test.ts +103 -155
  45. package/src/document/documentStore.ts +247 -237
  46. package/src/document/listen.ts +56 -55
  47. package/src/document/patchOperations.ts +0 -43
  48. package/src/document/permissions.test.ts +25 -12
  49. package/src/document/permissions.ts +11 -4
  50. package/src/document/processActions.test.ts +41 -8
  51. package/src/document/reducers.test.ts +87 -16
  52. package/src/document/reducers.ts +2 -2
  53. package/src/document/sharedListener.test.ts +34 -16
  54. package/src/document/sharedListener.ts +33 -11
  55. package/src/preview/getPreviewState.test.ts +40 -39
  56. package/src/preview/getPreviewState.ts +68 -56
  57. package/src/preview/previewConstants.ts +43 -0
  58. package/src/preview/previewQuery.test.ts +1 -1
  59. package/src/preview/previewQuery.ts +4 -5
  60. package/src/preview/previewStore.test.ts +13 -58
  61. package/src/preview/previewStore.ts +7 -21
  62. package/src/preview/resolvePreview.test.ts +33 -104
  63. package/src/preview/resolvePreview.ts +11 -21
  64. package/src/preview/subscribeToStateAndFetchBatches.test.ts +96 -97
  65. package/src/preview/subscribeToStateAndFetchBatches.ts +85 -81
  66. package/src/preview/util.ts +1 -0
  67. package/src/project/project.test.ts +3 -3
  68. package/src/project/project.ts +28 -5
  69. package/src/projection/getProjectionState.test.ts +69 -49
  70. package/src/projection/getProjectionState.ts +42 -50
  71. package/src/projection/projectionQuery.ts +1 -1
  72. package/src/projection/projectionStore.test.ts +13 -51
  73. package/src/projection/projectionStore.ts +6 -18
  74. package/src/projection/resolveProjection.test.ts +32 -127
  75. package/src/projection/resolveProjection.ts +15 -28
  76. package/src/projection/subscribeToStateAndFetchBatches.test.ts +105 -90
  77. package/src/projection/subscribeToStateAndFetchBatches.ts +94 -81
  78. package/src/projection/util.ts +2 -0
  79. package/src/projects/projects.test.ts +13 -4
  80. package/src/projects/projects.ts +6 -1
  81. package/src/query/queryStore.test.ts +10 -47
  82. package/src/query/queryStore.ts +151 -133
  83. package/src/query/queryStoreConstants.ts +2 -0
  84. package/src/store/createActionBinder.test.ts +153 -0
  85. package/src/store/createActionBinder.ts +176 -0
  86. package/src/store/createSanityInstance.test.ts +84 -0
  87. package/src/store/createSanityInstance.ts +124 -0
  88. package/src/store/createStateSourceAction.test.ts +196 -0
  89. package/src/store/createStateSourceAction.ts +260 -0
  90. package/src/store/createStoreInstance.test.ts +81 -0
  91. package/src/store/createStoreInstance.ts +80 -0
  92. package/src/store/createStoreState.test.ts +85 -0
  93. package/src/store/createStoreState.ts +92 -0
  94. package/src/store/defineStore.test.ts +18 -0
  95. package/src/store/defineStore.ts +81 -0
  96. package/src/users/reducers.test.ts +318 -0
  97. package/src/users/reducers.ts +88 -0
  98. package/src/users/types.ts +46 -4
  99. package/src/users/usersConstants.ts +4 -0
  100. package/src/users/usersStore.test.ts +350 -223
  101. package/src/users/usersStore.ts +285 -149
  102. package/src/utils/createFetcherStore.test.ts +6 -7
  103. package/src/utils/createFetcherStore.ts +150 -153
  104. package/src/{common/util.test.ts → utils/hashString.test.ts} +1 -1
  105. package/src/auth/fetchLoginUrls.test.ts +0 -163
  106. package/src/auth/fetchLoginUrls.ts +0 -74
  107. package/src/common/createLiveEventSubscriber.test.ts +0 -121
  108. package/src/common/createLiveEventSubscriber.ts +0 -55
  109. package/src/common/types.ts +0 -4
  110. package/src/instance/identity.test.ts +0 -46
  111. package/src/instance/identity.ts +0 -29
  112. package/src/instance/sanityInstance.test.ts +0 -77
  113. package/src/instance/sanityInstance.ts +0 -57
  114. package/src/instance/types.ts +0 -37
  115. package/src/preview/getPreviewProjection.ts +0 -45
  116. package/src/resources/README.md +0 -370
  117. package/src/resources/createAction.test.ts +0 -101
  118. package/src/resources/createAction.ts +0 -44
  119. package/src/resources/createResource.test.ts +0 -112
  120. package/src/resources/createResource.ts +0 -102
  121. package/src/resources/createStateSourceAction.test.ts +0 -114
  122. package/src/resources/createStateSourceAction.ts +0 -83
  123. package/src/resources/createStore.test.ts +0 -67
  124. package/src/resources/createStore.ts +0 -46
  125. package/src/store/createStore.test.ts +0 -108
  126. package/src/store/createStore.ts +0 -106
  127. /package/src/{common/util.ts → utils/hashString.ts} +0 -0
@@ -1,267 +1,394 @@
1
- import {type ResourceType, type SanityUser} from '@sanity/sdk'
2
- import {firstValueFrom} from 'rxjs'
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {delay, filter, firstValueFrom, Observable, of} from 'rxjs'
3
3
  import {beforeEach, describe, expect, it, vi} from 'vitest'
4
4
 
5
- import {getClient} from '../client/clientStore'
6
- import {createSanityInstance} from '../instance/sanityInstance'
7
- import {createResourceState, type ResourceState} from '../resources/createResource'
8
- import {createUsersStore, type UsersStoreState} from './usersStore'
5
+ import {getClientState} from '../client/clientStore'
6
+ import {createSanityInstance} from '../store/createSanityInstance'
7
+ import {type StateSource} from '../store/createStateSourceAction'
8
+ import {type GetUsersOptions, type SanityUser, type SanityUserResponse} from './types'
9
+ import {getUsersState, loadMoreUsers, resolveUsers} from './usersStore'
9
10
 
10
- vi.mock('../client/clientStore', () => ({
11
- getClient: vi.fn().mockReturnValue({
12
- request: vi.fn().mockImplementation(() => ({
13
- data: [],
14
- totalCount: 0,
15
- nextCursor: null,
16
- })),
17
- }),
11
+ vi.mock('./usersConstants', async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import('./usersConstants')>()),
13
+ USERS_STATE_CLEAR_DELAY: 10,
18
14
  }))
19
15
 
20
- describe('resource initialization', () => {
21
- it('should have correct default initial state', () => {
22
- const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
23
- const defaultState = createUsersStore(instance)
16
+ vi.mock('../client/clientStore')
24
17
 
25
- expect(defaultState.getState().getCurrent()).toEqual({
26
- users: [],
27
- totalCount: 0,
28
- nextCursor: null,
29
- hasMore: false,
30
- initialFetchCompleted: false,
31
- options: {
32
- resourceType: '',
33
- resourceId: '',
34
- limit: 100,
18
+ describe('usersStore', () => {
19
+ let request: SanityClient['observable']['request']
20
+
21
+ const mockUsers: SanityUser[] = [
22
+ {
23
+ sanityUserId: 'user1',
24
+ profile: {
25
+ id: 'profile1',
26
+ displayName: 'User 1',
27
+ email: 'user1@example.com',
28
+ provider: 'google',
29
+ createdAt: '2023-01-01T00:00:00Z',
35
30
  },
36
- })
37
- })
38
- })
31
+ memberships: [
32
+ {
33
+ resourceType: 'project',
34
+ resourceId: 'project1',
35
+ roleNames: ['viewer'],
36
+ },
37
+ ],
38
+ },
39
+ {
40
+ sanityUserId: 'user2',
41
+ profile: {
42
+ id: 'profile2',
43
+ displayName: 'User 2',
44
+ email: 'user2@example.com',
45
+ provider: 'google',
46
+ createdAt: '2023-01-02T00:00:00Z',
47
+ },
48
+ memberships: [
49
+ {
50
+ resourceType: 'project',
51
+ resourceId: 'project1',
52
+ roleNames: ['editor'],
53
+ },
54
+ ],
55
+ },
56
+ ]
39
57
 
40
- describe('usersStore', () => {
41
- let state: ResourceState<UsersStoreState>
42
- let instance: ReturnType<typeof createSanityInstance>
58
+ const mockResponse: SanityUserResponse = {
59
+ data: mockUsers,
60
+ totalCount: 2,
61
+ nextCursor: null,
62
+ }
43
63
 
44
64
  beforeEach(() => {
45
- instance = createSanityInstance({projectId: 'test', dataset: 'test'})
46
- state = createResourceState<UsersStoreState>(
47
- {
48
- users: [],
49
- totalCount: 0,
50
- nextCursor: null,
51
- hasMore: false,
52
- initialFetchCompleted: false,
53
- options: {
54
- resourceType: 'organization',
55
- resourceId: 'org123',
56
- limit: 100,
65
+ request = vi.fn().mockReturnValue(of(mockResponse).pipe(delay(0)))
66
+
67
+ vi.mocked(getClientState).mockReturnValue({
68
+ observable: of({
69
+ observable: {
70
+ request,
57
71
  },
58
- },
59
- {name: 'users'},
60
- )
72
+ } as SanityClient),
73
+ } as StateSource<SanityClient>)
61
74
  })
62
75
 
63
- describe('getState', () => {
64
- it('should return initial state', async () => {
65
- const store = createUsersStore({state, instance})
66
- const state$ = store.getState().observable
67
-
68
- await expect(firstValueFrom(state$)).resolves.toEqual({
69
- users: [],
70
- totalCount: 0,
71
- nextCursor: null,
72
- hasMore: false,
73
- initialFetchCompleted: false,
74
- options: {
75
- resourceType: 'organization',
76
- resourceId: 'org123',
77
- limit: 100,
78
- },
79
- })
76
+ it('initializes users state and cleans up after unsubscribe', async () => {
77
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
78
+ const state = getUsersState(instance, {
79
+ resourceType: 'project',
80
+ projectId: 'project1',
80
81
  })
81
82
 
82
- it('should return resolved users', async () => {
83
- const mockUsers = [{profile: {id: '1', displayName: 'Test User'}}] as unknown as SanityUser[]
84
- state.set('resolveUsers', {
85
- users: mockUsers,
86
- totalCount: 1,
87
- nextCursor: null,
88
- hasMore: false,
89
- initialFetchCompleted: true,
90
- })
91
-
92
- const store = createUsersStore({state, instance})
93
- const state$ = store.getState().observable
94
-
95
- await expect(firstValueFrom(state$)).resolves.toMatchObject({
96
- users: mockUsers,
97
- totalCount: 1,
98
- initialFetchCompleted: true,
99
- })
83
+ // Initially undefined before subscription
84
+ expect(state.getCurrent()).toBeUndefined()
85
+
86
+ // Subscribe to start fetching
87
+ const unsubscribe = state.subscribe()
88
+
89
+ // Wait for data to be fetched
90
+ await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
91
+
92
+ // Verify data is present
93
+ expect(state.getCurrent()).toEqual({
94
+ data: mockUsers,
95
+ totalCount: 2,
96
+ hasMore: false,
100
97
  })
98
+
99
+ // Unsubscribe to trigger cleanup
100
+ unsubscribe()
101
+
102
+ // Wait for the cleanup delay
103
+ await new Promise((resolve) => setTimeout(resolve, 20))
104
+
105
+ // Verify state is cleared
106
+ expect(state.getCurrent()).toBeUndefined()
107
+
108
+ instance.dispose()
101
109
  })
102
110
 
103
- describe('resolveUsers', () => {
104
- it('should fetch and store users', async () => {
105
- const mockResponse = {
106
- data: [{profile: {id: '1'}}] as SanityUser[],
107
- totalCount: 1,
108
- nextCursor: 'cursor123',
109
- }
110
- vi.mocked(getClient(instance, {apiVersion: 'vX'}).request).mockResolvedValueOnce(mockResponse)
111
-
112
- const store = createUsersStore({state, instance})
113
- const result = await store.resolveUsers()
114
-
115
- expect(getClient(instance, {apiVersion: 'vX'}).request).toHaveBeenCalledWith({
116
- method: 'GET',
117
- uri: `access/organization/org123/users`,
118
- query: {limit: '100'},
119
- tag: 'users',
120
- })
121
- expect(result).toEqual(mockResponse)
122
- expect(state.get()).toMatchObject({
123
- users: mockResponse.data,
124
- totalCount: mockResponse.totalCount,
125
- nextCursor: mockResponse.nextCursor,
126
- initialFetchCompleted: true,
127
- })
111
+ it('maintains state when multiple subscribers exist', async () => {
112
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
113
+ const state = getUsersState(instance, {
114
+ resourceType: 'project',
115
+ projectId: 'project1',
128
116
  })
129
117
 
130
- it('should throw error when missing resource info', async () => {
131
- state.set('options', {options: {resourceType: '' as ResourceType, resourceId: ''}})
132
- const store = createUsersStore({state, instance})
118
+ // Add two subscribers
119
+ const unsubscribe1 = state.subscribe()
120
+ const unsubscribe2 = state.subscribe()
121
+
122
+ // Wait for data to be fetched
123
+ await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
133
124
 
134
- await expect(store.resolveUsers()).rejects.toThrow(
135
- 'Resource type and ID are required to resolve users',
136
- )
125
+ // Verify data is present
126
+ expect(state.getCurrent()).toEqual({
127
+ data: mockUsers,
128
+ totalCount: 2,
129
+ hasMore: false,
130
+ })
131
+
132
+ // Remove first subscriber
133
+ unsubscribe1()
134
+
135
+ // Data should still be present due to second subscriber
136
+ expect(state.getCurrent()).toEqual({
137
+ data: mockUsers,
138
+ totalCount: 2,
139
+ hasMore: false,
137
140
  })
141
+
142
+ // Remove second subscriber
143
+ unsubscribe2()
144
+
145
+ // Wait for cleanup delay
146
+ await new Promise((resolve) => setTimeout(resolve, 20))
147
+
148
+ // Verify state is cleared after all subscribers are gone
149
+ expect(state.getCurrent()).toBeUndefined()
150
+
151
+ instance.dispose()
138
152
  })
139
153
 
140
- describe('loadMore', () => {
141
- it('should fetch and append more users', async () => {
142
- const initialUsers = Array(10).fill({profile: {id: '1'}}) as SanityUser[]
143
- const newUsers = Array(5).fill({profile: {id: '2'}}) as SanityUser[]
144
-
145
- state.set('resolveUsers', {
146
- users: initialUsers,
147
- totalCount: 15,
148
- nextCursor: 'cursor123',
149
- hasMore: true,
150
- initialFetchCompleted: true,
151
- })
152
-
153
- vi.mocked(getClient(instance, {apiVersion: 'xV'}).request).mockResolvedValueOnce({
154
- data: newUsers,
155
- totalCount: 15,
156
- nextCursor: null,
157
- })
158
-
159
- const store = createUsersStore({state, instance})
160
- await store.loadMore()
161
-
162
- expect(state.get()).toMatchObject({
163
- users: [...initialUsers, ...newUsers],
164
- totalCount: 15,
165
- nextCursor: null,
166
- hasMore: false,
167
- })
154
+ it('resolveUsers works without affecting subscriber cleanup', async () => {
155
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
156
+ const options: GetUsersOptions = {resourceType: 'project', projectId: 'project1'}
157
+
158
+ const state = getUsersState(instance, options)
159
+
160
+ // Check that getUsersState starts undefined
161
+ expect(state.getCurrent()).toBeUndefined()
162
+
163
+ // Use resolveUsers which should not add a subscriber
164
+ const result = await resolveUsers(instance, options)
165
+ expect(result).toEqual({
166
+ data: mockUsers,
167
+ totalCount: 2,
168
+ hasMore: false,
168
169
  })
169
170
 
170
- it('should throw error when missing resource info', async () => {
171
- state.set('options', {options: {resourceType: '' as ResourceType, resourceId: ''}})
172
- const store = createUsersStore({state, instance})
171
+ // Check that getUsersState starts resolved now
172
+ expect(state.getCurrent()).toEqual({
173
+ data: mockUsers,
174
+ totalCount: 2,
175
+ hasMore: false,
176
+ })
177
+
178
+ // Subscribing and unsubscribing should clear the state
179
+ const unsubscribe = state.subscribe()
180
+ unsubscribe()
181
+ await new Promise((resolve) => setTimeout(resolve, 20))
182
+ expect(state.getCurrent()).toBeUndefined()
173
183
 
174
- await expect(store.loadMore()).rejects.toThrow(
175
- 'Resource type and ID are required to load more users',
176
- )
184
+ instance.dispose()
185
+ })
186
+
187
+ it('handles abort signal in resolveUsers', async () => {
188
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
189
+ const options: GetUsersOptions = {resourceType: 'project', projectId: 'project1'}
190
+ const abortController = new AbortController()
191
+
192
+ // Create a promise that will reject when aborted
193
+ const usersPromise = resolveUsers(instance, {
194
+ ...options,
195
+ signal: abortController.signal,
177
196
  })
197
+
198
+ // Abort the request
199
+ abortController.abort()
200
+
201
+ // Verify the promise rejects with AbortError
202
+ await expect(usersPromise).rejects.toThrow('The operation was aborted.')
203
+
204
+ // Verify state is cleared after abort
205
+ expect(getUsersState(instance, options).getCurrent()).toBeUndefined()
206
+
207
+ instance.dispose()
178
208
  })
179
209
 
180
- describe('setOptions', () => {
181
- it('should update resource options', () => {
182
- const store = createUsersStore({state, instance})
183
- store.setOptions({
184
- resourceType: 'project',
185
- resourceId: 'proj456',
186
- })
187
-
188
- expect(state.get().options).toMatchObject({
189
- resourceType: 'project',
190
- resourceId: 'proj456',
191
- })
210
+ it('loads more users when loadMoreUsers is called', async () => {
211
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
212
+ const options: GetUsersOptions = {resourceType: 'project', projectId: 'project1'}
213
+
214
+ // First response has nextCursor
215
+ const firstResponse = {
216
+ ...mockResponse,
217
+ nextCursor: 'next-page',
218
+ }
219
+
220
+ // Additional users for the second page
221
+ const additionalUsers: SanityUser[] = [
222
+ {
223
+ sanityUserId: 'user3',
224
+ profile: {
225
+ id: 'profile3',
226
+ displayName: 'User 3',
227
+ email: 'user3@example.com',
228
+ provider: 'google',
229
+ createdAt: '2023-01-03T00:00:00Z',
230
+ },
231
+ memberships: [
232
+ {
233
+ resourceType: 'project',
234
+ resourceId: 'project1',
235
+ roleNames: ['admin'],
236
+ },
237
+ ],
238
+ },
239
+ ]
240
+
241
+ // Second response has no nextCursor
242
+ const secondResponse = {
243
+ data: additionalUsers,
244
+ totalCount: 3,
245
+ nextCursor: null,
246
+ }
247
+
248
+ // Setup request mock to return different responses
249
+ vi.mocked(request).mockReset()
250
+ vi.mocked(request).mockImplementationOnce(() => of(firstResponse).pipe(delay(0)))
251
+ vi.mocked(request).mockImplementationOnce(() => of(secondResponse).pipe(delay(0)))
252
+
253
+ const state = getUsersState(instance, options)
254
+ const unsubscribe = state.subscribe()
255
+
256
+ // Wait for initial data
257
+ await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
258
+
259
+ // Verify initial data
260
+ expect(state.getCurrent()).toEqual({
261
+ data: mockUsers,
262
+ totalCount: 2,
263
+ hasMore: true,
192
264
  })
193
265
 
194
- it('should reset state when options change', () => {
195
- const store = createUsersStore({state, instance})
196
- store.setOptions({
197
- resourceType: 'project',
198
- resourceId: 'proj456',
199
- })
200
-
201
- expect(state.get()).toMatchObject({
202
- users: [],
203
- totalCount: 0,
204
- nextCursor: null,
205
- hasMore: false,
206
- initialFetchCompleted: false,
207
- })
266
+ // Load more users
267
+ await loadMoreUsers(instance, options)
268
+
269
+ // Verify updated data includes both pages
270
+ expect(state.getCurrent()).toEqual({
271
+ data: [...mockUsers, ...additionalUsers],
272
+ totalCount: 3,
273
+ hasMore: false,
208
274
  })
209
275
 
210
- it('should preserve existing limit when updating options', () => {
211
- const store = createUsersStore({state, instance})
212
- store.setOptions({
213
- resourceType: 'project',
214
- resourceId: 'proj456',
215
- })
216
-
217
- expect(state.get().options).toMatchObject({
218
- resourceType: 'project',
219
- resourceId: 'proj456',
220
- limit: 100, // Preserves original limit from initial state
221
- })
276
+ unsubscribe()
277
+ instance.dispose()
278
+ })
279
+
280
+ it('throws error when loadMoreUsers is called without initial data', async () => {
281
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
282
+
283
+ // Expect loadMoreUsers to throw when no data is loaded
284
+ await expect(
285
+ loadMoreUsers(instance, {resourceType: 'project', projectId: 'project1'}),
286
+ ).rejects.toThrow('Users not loaded for specified resource')
287
+
288
+ instance.dispose()
289
+ })
290
+
291
+ it('throws error when loadMoreUsers is called with no more data available', async () => {
292
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
293
+ const options: GetUsersOptions = {resourceType: 'project', projectId: 'project1'}
294
+
295
+ // Response with no nextCursor
296
+ vi.mocked(request).mockReset()
297
+ vi.mocked(request).mockImplementationOnce(() => of(mockResponse).pipe(delay(0)))
298
+
299
+ const state = getUsersState(instance, options)
300
+ const unsubscribe = state.subscribe()
301
+
302
+ // Wait for data to be fetched
303
+ await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
304
+
305
+ // Expect loadMoreUsers to throw when hasMore is false
306
+ await expect(loadMoreUsers(instance, options)).rejects.toThrow(
307
+ 'No more users available to load for this resource',
308
+ )
309
+
310
+ unsubscribe()
311
+ instance.dispose()
312
+ })
313
+
314
+ it('handles errors in users fetching', async () => {
315
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
316
+ const errorMessage = 'Failed to fetch users'
317
+
318
+ // Override request to simulate error
319
+ vi.mocked(request).mockReset()
320
+ vi.mocked(request).mockImplementationOnce(
321
+ () =>
322
+ new Observable((observer) => {
323
+ observer.error(new Error(errorMessage))
324
+ }),
325
+ )
326
+
327
+ const state = getUsersState(instance, {
328
+ resourceType: 'project',
329
+ projectId: 'project1',
222
330
  })
331
+ const unsubscribe = state.subscribe()
332
+
333
+ // Verify error is thrown when accessing state
334
+ expect(() => state.getCurrent()).toThrow(errorMessage)
335
+
336
+ unsubscribe()
337
+ instance.dispose()
338
+ })
339
+
340
+ it('delays users state removal after unsubscribe', async () => {
341
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
342
+ const options: GetUsersOptions = {resourceType: 'project', projectId: 'project1'}
343
+ const state = getUsersState(instance, options)
344
+ const unsubscribe = state.subscribe()
345
+
346
+ await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
347
+
348
+ unsubscribe()
349
+ // Immediately after unsubscription, state should still be present due to delay
350
+ expect(state.getCurrent()).not.toBeUndefined()
351
+
352
+ // Wait for the cleanup delay and then state should be removed
353
+ await new Promise((resolve) => setTimeout(resolve, 20))
354
+ expect(state.getCurrent()).toBeUndefined()
355
+
356
+ instance.dispose()
223
357
  })
224
358
 
225
- describe('edge cases', () => {
226
- it('should handle empty response', async () => {
227
- vi.mocked(getClient(instance, {apiVersion: 'vX'}).request).mockResolvedValueOnce({
228
- data: [],
229
- totalCount: 0,
230
- nextCursor: null,
231
- })
232
-
233
- const store = createUsersStore({state, instance})
234
- await store.resolveUsers()
235
-
236
- expect(state.get()).toMatchObject({
237
- users: [],
238
- totalCount: 0,
239
- initialFetchCompleted: true,
240
- })
359
+ it('preserves users state if a new subscriber subscribes before cleanup delay', async () => {
360
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
361
+ const state = getUsersState(instance, {
362
+ resourceType: 'project',
363
+ projectId: 'project1',
241
364
  })
365
+ const unsubscribe1 = state.subscribe()
242
366
 
243
- it('should handle no more results', async () => {
244
- state.set('resolveUsers', {
245
- users: Array(10).fill({}),
246
- totalCount: 10,
247
- nextCursor: null,
248
- hasMore: false,
249
- initialFetchCompleted: true,
250
- })
367
+ await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
368
+ expect(state.getCurrent()).toEqual({
369
+ data: mockUsers,
370
+ totalCount: 2,
371
+ hasMore: false,
372
+ })
251
373
 
252
- const store = createUsersStore({state, instance})
253
- await store.loadMore()
374
+ unsubscribe1()
375
+ // Wait less than the cleanup delay
376
+ await new Promise((resolve) => setTimeout(resolve, 5))
254
377
 
255
- // Shouldn't change state
256
- expect(state.get().users.length).toBe(10)
378
+ // Subscribe again before cleanup occurs
379
+ const unsubscribe2 = state.subscribe()
380
+
381
+ // Wait for cleanup delay to pass
382
+ await new Promise((resolve) => setTimeout(resolve, 20))
383
+
384
+ // Since a subscriber now exists, state should still be present
385
+ expect(state.getCurrent()).toEqual({
386
+ data: mockUsers,
387
+ totalCount: 2,
388
+ hasMore: false,
257
389
  })
258
- })
259
- })
260
390
 
261
- it('should call getClient with proper parameters', () => {
262
- const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
263
- const store = createUsersStore(instance)
264
- store.setOptions({resourceType: 'organization', resourceId: 'org123'})
265
- store.resolveUsers()
266
- expect(getClient).toHaveBeenCalledWith(instance, {apiVersion: 'vX', scope: 'global'})
391
+ unsubscribe2()
392
+ instance.dispose()
393
+ })
267
394
  })