@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.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.
Files changed (197) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/_internal.js +134 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  7. package/dist/_chunks-es/telemetryManager.js +93 -0
  8. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  9. package/dist/_exports/_internal.d.ts +95 -0
  10. package/dist/_exports/_internal.d.ts.map +1 -0
  11. package/dist/_exports/_internal.js +24 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +1079 -1076
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1967 -2302
  16. package/dist/index.js.map +1 -1
  17. package/package.json +48 -47
  18. package/src/_exports/_internal.ts +26 -0
  19. package/src/_exports/index.ts +120 -11
  20. package/src/access/checkPermissions.test.ts +55 -0
  21. package/src/access/checkPermissions.ts +54 -0
  22. package/src/agent/agentActions.test.ts +16 -60
  23. package/src/agent/agentActions.ts +25 -38
  24. package/src/applications/applications.test-d.ts +83 -0
  25. package/src/applications/applications.test.ts +130 -0
  26. package/src/applications/applications.ts +364 -0
  27. package/src/applications/userApplications.test.ts +126 -0
  28. package/src/applications/userApplications.ts +261 -0
  29. package/src/auth/authLogger.ts +30 -0
  30. package/src/auth/authMode.test.ts +25 -0
  31. package/src/auth/authMode.ts +6 -3
  32. package/src/auth/authStore.test.ts +194 -13
  33. package/src/auth/authStore.ts +59 -26
  34. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  35. package/src/auth/getOrganizationVerificationState.ts +4 -4
  36. package/src/auth/handleAuthCallback.test.ts +35 -1
  37. package/src/auth/handleAuthCallback.ts +25 -6
  38. package/src/auth/logout.test.ts +75 -2
  39. package/src/auth/logout.ts +23 -4
  40. package/src/auth/refreshStampedToken.test.ts +219 -469
  41. package/src/auth/refreshStampedToken.ts +79 -195
  42. package/src/auth/studioAuth.ts +1 -1
  43. package/src/auth/studioModeAuth.ts +2 -1
  44. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  45. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  46. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  47. package/src/client/clientStore.test.ts +62 -37
  48. package/src/client/clientStore.ts +45 -75
  49. package/src/client/liveEvents.test.ts +151 -0
  50. package/src/client/liveEvents.ts +107 -0
  51. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  52. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  53. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  54. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  55. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  56. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  57. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  58. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  59. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  60. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  61. package/src/comlink/node/getNodeState.test.ts +1 -1
  62. package/src/comlink/node/getNodeState.ts +2 -1
  63. package/src/config/__tests__/handles.test.ts +18 -12
  64. package/src/config/handles.ts +25 -7
  65. package/src/config/sanityConfig.ts +109 -79
  66. package/src/datasets/datasets.test.ts +2 -2
  67. package/src/datasets/datasets.ts +10 -4
  68. package/src/document/actions.test.ts +115 -33
  69. package/src/document/actions.ts +212 -15
  70. package/src/document/applyDocumentActions.test.ts +38 -12
  71. package/src/document/applyDocumentActions.ts +30 -9
  72. package/src/document/documentConstants.ts +7 -0
  73. package/src/document/documentStore.concurrency.test.ts +1306 -0
  74. package/src/document/documentStore.test.ts +673 -278
  75. package/src/document/documentStore.ts +162 -71
  76. package/src/document/events.test.ts +57 -2
  77. package/src/document/events.ts +82 -26
  78. package/src/document/listen.test.ts +56 -0
  79. package/src/document/listen.ts +74 -21
  80. package/src/document/listenerEventOperators.test.ts +311 -0
  81. package/src/document/listenerEventOperators.ts +217 -0
  82. package/src/document/patchOperations.test.ts +49 -2
  83. package/src/document/patchOperations.ts +50 -0
  84. package/src/document/permissions.test.ts +105 -60
  85. package/src/document/permissions.ts +44 -19
  86. package/src/document/processActions/create.ts +138 -0
  87. package/src/document/processActions/delete.ts +100 -0
  88. package/src/document/processActions/discard.ts +63 -0
  89. package/src/document/processActions/edit.ts +154 -0
  90. package/src/document/processActions/processActions.ts +218 -0
  91. package/src/document/processActions/publish.ts +120 -0
  92. package/src/document/processActions/releaseArchive.ts +77 -0
  93. package/src/document/processActions/releaseCreate.ts +59 -0
  94. package/src/document/processActions/releaseDelete.ts +65 -0
  95. package/src/document/processActions/releaseEdit.ts +37 -0
  96. package/src/document/processActions/releasePublish.ts +45 -0
  97. package/src/document/processActions/releaseSchedule.ts +87 -0
  98. package/src/document/processActions/releaseUtil.ts +31 -0
  99. package/src/document/processActions/shared.ts +308 -0
  100. package/src/document/processActions/unpublish.ts +85 -0
  101. package/src/document/processActions.test.ts +1129 -121
  102. package/src/document/reducers.test.ts +639 -64
  103. package/src/document/reducers.ts +187 -48
  104. package/src/document/resourceRules.test.ts +178 -0
  105. package/src/document/resourceRules.ts +117 -0
  106. package/src/document/sharedListener.test.ts +16 -14
  107. package/src/document/sharedListener.ts +24 -8
  108. package/src/document/util.ts +14 -0
  109. package/src/favorites/favorites.test.ts +5 -6
  110. package/src/installations/installations.test-d.ts +68 -0
  111. package/src/installations/installations.test.ts +62 -0
  112. package/src/installations/installations.ts +202 -0
  113. package/src/organization/organization.test-d.ts +36 -0
  114. package/src/organization/organization.test.ts +138 -0
  115. package/src/organization/organization.ts +138 -0
  116. package/src/organizations/organizations.test-d.ts +56 -0
  117. package/src/organizations/organizations.test.ts +150 -0
  118. package/src/organizations/organizations.ts +107 -0
  119. package/src/presence/bifurTransport.test.ts +46 -6
  120. package/src/presence/bifurTransport.ts +19 -2
  121. package/src/presence/presenceStore.test.ts +94 -23
  122. package/src/presence/presenceStore.ts +85 -29
  123. package/src/preview/getPreviewState.test.ts +137 -0
  124. package/src/preview/getPreviewState.ts +69 -0
  125. package/src/preview/previewProjectionUtils.test.ts +9 -22
  126. package/src/preview/previewProjectionUtils.ts +7 -3
  127. package/src/preview/resolvePreview.test.ts +64 -0
  128. package/src/preview/resolvePreview.ts +43 -0
  129. package/src/preview/types.ts +10 -0
  130. package/src/project/project.test-d.ts +37 -0
  131. package/src/project/project.test.ts +157 -12
  132. package/src/project/project.ts +138 -25
  133. package/src/projection/getProjectionState.test.ts +18 -20
  134. package/src/projection/getProjectionState.ts +23 -3
  135. package/src/projection/projectionQuery.ts +5 -5
  136. package/src/projection/projectionStore.test.ts +1 -1
  137. package/src/projection/resolveProjection.test.ts +1 -2
  138. package/src/projection/resolveProjection.ts +16 -0
  139. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  140. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  141. package/src/projection/types.ts +8 -0
  142. package/src/projects/projects.test-d.ts +32 -0
  143. package/src/projects/projects.test.ts +108 -42
  144. package/src/projects/projects.ts +63 -27
  145. package/src/query/queryStore.test.ts +259 -123
  146. package/src/query/queryStore.ts +78 -63
  147. package/src/query/reducers.test.ts +0 -42
  148. package/src/query/reducers.ts +2 -18
  149. package/src/releases/getPerspectiveState.test.ts +17 -23
  150. package/src/releases/getPerspectiveState.ts +8 -20
  151. package/src/releases/observeReleases.test.ts +127 -0
  152. package/src/releases/observeReleases.ts +102 -0
  153. package/src/releases/releasesStore.test.ts +100 -53
  154. package/src/releases/releasesStore.ts +69 -44
  155. package/src/releases/utils/sortReleases.test.ts +38 -2
  156. package/src/releases/utils/sortReleases.ts +15 -10
  157. package/src/store/createActionBinder.test.ts +87 -90
  158. package/src/store/createActionBinder.ts +79 -25
  159. package/src/store/createSanityInstance.test.ts +83 -13
  160. package/src/store/createSanityInstance.ts +77 -2
  161. package/src/store/createStateSourceAction.test.ts +64 -2
  162. package/src/store/createStateSourceAction.ts +39 -44
  163. package/src/store/createStoreInstance.test.ts +4 -2
  164. package/src/store/fetcherStore.test-d.ts +121 -0
  165. package/src/store/fetcherStore.test.ts +299 -0
  166. package/src/store/fetcherStore.ts +801 -0
  167. package/src/telemetry/environment.test.ts +119 -0
  168. package/src/telemetry/environment.ts +92 -0
  169. package/src/telemetry/events.ts +42 -0
  170. package/src/telemetry/initTelemetry.test.ts +432 -0
  171. package/src/telemetry/initTelemetry.ts +218 -0
  172. package/src/telemetry/telemetryManager.test.ts +327 -0
  173. package/src/telemetry/telemetryManager.ts +199 -0
  174. package/src/users/reducers.test.ts +6 -1
  175. package/src/users/reducers.ts +5 -6
  176. package/src/users/types.ts +4 -4
  177. package/src/users/usersStore.test.ts +16 -12
  178. package/src/users/usersStore.ts +5 -1
  179. package/src/utils/buildQuery.test.ts +14 -0
  180. package/src/utils/buildQuery.ts +17 -0
  181. package/src/utils/createFetcherStore.test.ts +7 -5
  182. package/src/utils/createFetcherStore.ts +8 -5
  183. package/src/utils/getStagingApiHost.test.ts +21 -0
  184. package/src/utils/getStagingApiHost.ts +14 -0
  185. package/src/utils/ids.test.ts +1 -29
  186. package/src/utils/ids.ts +0 -10
  187. package/src/utils/includeShape.ts +36 -0
  188. package/src/utils/isImportError.test.ts +72 -0
  189. package/src/utils/isImportError.ts +34 -0
  190. package/src/utils/logger.test.ts +12 -0
  191. package/src/utils/logger.ts +8 -3
  192. package/src/utils/object.test.ts +95 -0
  193. package/src/utils/object.ts +142 -0
  194. package/src/utils/setCleanupTimeout.ts +37 -0
  195. package/src/document/processActions.ts +0 -707
  196. package/src/utils/defineIntent.test.ts +0 -477
  197. package/src/utils/defineIntent.ts +0 -244
@@ -1,4 +1,4 @@
1
- import {isEqual} from 'lodash-es'
1
+ import {DocumentId} from '@sanity/id-utils'
2
2
  import {
3
3
  combineLatest,
4
4
  debounceTime,
@@ -19,6 +19,7 @@ import {
19
19
  import {getQueryState, resolveQuery} from '../query/queryStore'
20
20
  import {type BoundPerspectiveKey} from '../store/createActionBinder'
21
21
  import {type StoreContext} from '../store/defineStore'
22
+ import {isDeepEqual} from '../utils/object'
22
23
  import {
23
24
  createProjectionQuery,
24
25
  processProjectionQuery,
@@ -45,18 +46,18 @@ export const subscribeToStateAndFetchBatches = ({
45
46
  }: StoreContext<ProjectionStoreState, BoundPerspectiveKey>): Subscription => {
46
47
  const documentProjections$ = state.observable.pipe(
47
48
  map((s) => s.documentProjections),
48
- distinctUntilChanged(isEqual),
49
+ distinctUntilChanged(isDeepEqual),
49
50
  )
50
51
 
51
52
  const activeDocumentIds$ = state.observable.pipe(
52
- map(({subscriptions}) => new Set(Object.keys(subscriptions))),
53
+ map(({subscriptions}) => new Set(Object.keys(subscriptions).map((id) => DocumentId(id)))),
53
54
  distinctUntilChanged(isSetEqual),
54
55
  )
55
56
 
56
57
  const pendingUpdateSubscription = activeDocumentIds$
57
58
  .pipe(
58
59
  debounceTime(BATCH_DEBOUNCE_TIME),
59
- startWith(new Set<string>()),
60
+ startWith(new Set<DocumentId>()),
60
61
  pairwise(),
61
62
  tap(([prevIds, currIds]) => {
62
63
  const newIds = [...currIds].filter((id) => !prevIds.has(id))
@@ -88,7 +89,7 @@ export const subscribeToStateAndFetchBatches = ({
88
89
 
89
90
  const queryTrigger$ = combineLatest([activeDocumentIds$, documentProjections$]).pipe(
90
91
  debounceTime(BATCH_DEBOUNCE_TIME),
91
- distinctUntilChanged(isEqual),
92
+ distinctUntilChanged(isDeepEqual),
92
93
  )
93
94
 
94
95
  const queryExecutionSubscription = queryTrigger$
@@ -105,10 +106,12 @@ export const subscribeToStateAndFetchBatches = ({
105
106
 
106
107
  const projectionQuery$ = new Observable<ProjectionQueryResult[]>((observer) => {
107
108
  const {getCurrent, observable} = getQueryState<ProjectionQueryResult[]>(instance, {
108
- query,
109
- params,
110
- tag: PROJECTION_TAG,
111
- perspective,
109
+ ...{
110
+ query,
111
+ params,
112
+ tag: PROJECTION_TAG,
113
+ perspective,
114
+ },
112
115
  resource,
113
116
  })
114
117
 
@@ -116,11 +119,13 @@ export const subscribeToStateAndFetchBatches = ({
116
119
  if (getCurrent() === undefined) {
117
120
  return from(
118
121
  resolveQuery<ProjectionQueryResult[]>(instance, {
119
- query,
120
- params,
121
- tag: PROJECTION_TAG,
122
- signal: controller.signal,
123
- perspective,
122
+ ...{
123
+ query,
124
+ params,
125
+ tag: PROJECTION_TAG,
126
+ signal: controller.signal,
127
+ perspective,
128
+ },
124
129
  resource,
125
130
  }),
126
131
  ).pipe(switchMap(() => observable))
@@ -140,10 +145,12 @@ export const subscribeToStateAndFetchBatches = ({
140
145
 
141
146
  const statusQuery$ = new Observable<StatusQueryResult[]>((observer) => {
142
147
  const {getCurrent, observable} = getQueryState<StatusQueryResult[]>(instance, {
143
- query: statusQuery,
144
- params: statusParams,
145
- tag: PROJECTION_TAG,
146
- perspective: 'raw',
148
+ ...{
149
+ query: statusQuery,
150
+ params: statusParams,
151
+ tag: PROJECTION_TAG,
152
+ perspective: 'raw',
153
+ },
147
154
  resource,
148
155
  })
149
156
 
@@ -151,11 +158,13 @@ export const subscribeToStateAndFetchBatches = ({
151
158
  if (getCurrent() === undefined) {
152
159
  return from(
153
160
  resolveQuery<StatusQueryResult[]>(instance, {
154
- query: statusQuery,
155
- params: statusParams,
156
- tag: PROJECTION_TAG,
157
- signal: controller.signal,
158
- perspective: 'raw',
161
+ ...{
162
+ query: statusQuery,
163
+ params: statusParams,
164
+ tag: PROJECTION_TAG,
165
+ signal: controller.signal,
166
+ perspective: 'raw',
167
+ },
159
168
  resource,
160
169
  }),
161
170
  ).pipe(switchMap(() => observable))
@@ -11,6 +11,14 @@ export interface DocumentProjectionValues<TValue extends object = object> {
11
11
  [projectionHash: string]: ProjectionValuePending<TValue>
12
12
  }
13
13
 
14
+ /**
15
+ * @public
16
+ * @deprecated
17
+ * Template literals are a bit too limited, so this type is deprecated.
18
+ * Use `string` instead. Projection strings are validated at runtime.
19
+ */
20
+ export type ValidProjection = string
21
+
14
22
  export interface DocumentProjections {
15
23
  [projectionHash: string]: string
16
24
  }
@@ -0,0 +1,32 @@
1
+ import {expectTypeOf, test} from 'vitest'
2
+
3
+ import {type Project, type ProjectMember} from '../project/project'
4
+ import {type SanityInstance} from '../store/createSanityInstance'
5
+ import {type StateSource} from '../store/createStateSourceAction'
6
+ import {type FetcherSnapshot} from '../store/fetcherStore'
7
+ import {projects} from './projects'
8
+
9
+ const instance = {} as SanityInstance
10
+
11
+ test('projects.resolveState — resolves the wide-boolean list shape', () => {
12
+ expectTypeOf(projects.resolveState(instance)).resolves.toEqualTypeOf<
13
+ Project<boolean, boolean>[]
14
+ >()
15
+ })
16
+
17
+ test('Project<boolean, boolean> — members and features are optional', () => {
18
+ type Item = Project<boolean, boolean>
19
+ expectTypeOf<Item['members']>().toEqualTypeOf<ProjectMember[] | undefined>()
20
+ expectTypeOf<Item['features']>().toEqualTypeOf<string[] | undefined>()
21
+ })
22
+
23
+ test('projects.getState — exposes the snapshot envelope', () => {
24
+ expectTypeOf(projects.getState(instance)).toEqualTypeOf<
25
+ StateSource<FetcherSnapshot<Project<boolean, boolean>[]>>
26
+ >()
27
+ })
28
+
29
+ test('projects — rejects non-boolean flag values', () => {
30
+ // @ts-expect-error includeMembers must be a boolean
31
+ void projects.resolveState(instance, {includeMembers: 'yes'})
32
+ })
@@ -5,7 +5,7 @@ import {afterEach, beforeEach, describe, it} from 'vitest'
5
5
  import {getClientState} from '../client/clientStore'
6
6
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
7
  import {type StateSource} from '../store/createStateSourceAction'
8
- import {resolveProjects} from './projects'
8
+ import {getProjectsCacheKey, projects} from './projects'
9
9
 
10
10
  vi.mock('../client/clientStore')
11
11
 
@@ -13,64 +13,130 @@ describe('projects', () => {
13
13
  let instance: SanityInstance
14
14
 
15
15
  beforeEach(() => {
16
- instance = createSanityInstance()
16
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
17
17
  })
18
18
 
19
19
  afterEach(() => {
20
20
  instance.dispose()
21
21
  })
22
22
 
23
- it('calls the `client.observable.projects.list` method on the client and returns the result', async () => {
24
- const projects = [{id: 'a'}, {id: 'b'}]
25
- const list = vi.fn().mockReturnValue(of(projects))
23
+ it('calls `client.observable.request` against `/projects` and returns the result', async () => {
24
+ const list = [{id: 'a'}, {id: 'b'}]
25
+ const request = vi.fn().mockReturnValue(of(list))
26
26
 
27
27
  const mockClient = {
28
- observable: {
29
- projects: {list} as unknown as SanityClient['observable']['projects'],
30
- },
28
+ observable: {request} as unknown as SanityClient['observable'],
31
29
  } as SanityClient
32
30
 
33
31
  vi.mocked(getClientState).mockReturnValue({
34
32
  observable: of(mockClient),
35
33
  } as StateSource<SanityClient>)
36
34
 
37
- const result = await resolveProjects(instance)
38
- expect(result).toEqual(projects)
39
- expect(list).toHaveBeenCalledWith({includeMembers: false, organizationId: undefined})
35
+ const result = await projects.resolveState(instance)
36
+ expect(result).toEqual(list)
37
+ expect(request).toHaveBeenCalledWith({
38
+ uri: '/projects',
39
+ query: {includeMembers: 'false', includeFeatures: 'true', onlyExplicitMembership: 'false'},
40
+ tag: 'projects.list',
41
+ })
40
42
  })
41
- })
42
43
 
43
- describe('projects cache key generation', () => {
44
- it('generates correct cache keys for different parameter combinations', async () => {
45
- // Test the getKey function directly by creating a mock store
46
- const mockGetKey = (
47
- _instance: SanityInstance,
48
- options?: {organizationId?: string; includeMembers?: boolean},
49
- ) => {
50
- const orgKey = options?.organizationId ? `:org:${options.organizationId}` : ''
51
- const membersKey = options?.includeMembers === false ? ':no-members' : ''
52
- return `projects${orgKey}${membersKey}`
53
- }
54
-
55
- const mockInstance = {} as SanityInstance
56
-
57
- // Test default behavior (no options)
58
- const defaultKey = mockGetKey(mockInstance)
59
- expect(defaultKey).toBe('projects')
60
-
61
- // Test with organizationId only
62
- const orgKey = mockGetKey(mockInstance, {organizationId: 'org123'})
63
- expect(orgKey).toBe('projects:org:org123')
64
-
65
- // Test with includeMembers: false only
66
- const noMembersKey = mockGetKey(mockInstance, {includeMembers: false})
67
- expect(noMembersKey).toBe('projects:no-members')
68
-
69
- // Test with both parameters
70
- const bothKey = mockGetKey(mockInstance, {
44
+ it('serializes query params (booleans → strings) and omits undefined values', async () => {
45
+ const request = vi.fn().mockReturnValue(of([]))
46
+ const mockClient = {
47
+ observable: {request} as unknown as SanityClient['observable'],
48
+ } as SanityClient
49
+
50
+ vi.mocked(getClientState).mockReturnValue({
51
+ observable: of(mockClient),
52
+ } as StateSource<SanityClient>)
53
+
54
+ await projects.resolveState(instance, {
71
55
  organizationId: 'org123',
72
- includeMembers: false,
56
+ includeMembers: true,
57
+ includeFeatures: false,
73
58
  })
74
- expect(bothKey).toBe('projects:org:org123:no-members')
59
+
60
+ expect(request).toHaveBeenCalledWith({
61
+ uri: '/projects',
62
+ query: {
63
+ organizationId: 'org123',
64
+ includeMembers: 'true',
65
+ includeFeatures: 'false',
66
+ onlyExplicitMembership: 'false',
67
+ },
68
+ tag: 'projects.list',
69
+ })
70
+ })
71
+ })
72
+
73
+ describe('projects cache key generation', () => {
74
+ let instance: SanityInstance
75
+
76
+ beforeEach(() => {
77
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
78
+ })
79
+
80
+ afterEach(() => {
81
+ instance.dispose()
82
+ })
83
+
84
+ it('default call includes :features (default-true) and excludes :members (default-false)', () => {
85
+ expect(getProjectsCacheKey(instance)).toBe('projects:features')
86
+ })
87
+
88
+ it('treats undefined and the matching default as the same key', () => {
89
+ expect(getProjectsCacheKey(instance)).toBe(
90
+ getProjectsCacheKey(instance, {includeMembers: false, includeFeatures: true}),
91
+ )
92
+ })
93
+
94
+ it('treats raw and explicit defaults equivalently', () => {
95
+ expect(getProjectsCacheKey(instance, {organizationId: 'org123'})).toBe(
96
+ getProjectsCacheKey(instance, {
97
+ organizationId: 'org123',
98
+ includeMembers: false,
99
+ includeFeatures: true,
100
+ onlyExplicitMembership: false,
101
+ }),
102
+ )
103
+ })
104
+
105
+ it('explicit includeFeatures: false drops the :features segment', () => {
106
+ expect(getProjectsCacheKey(instance, {includeFeatures: false})).toBe('projects')
107
+ })
108
+
109
+ it('appends an org segment when organizationId is set', () => {
110
+ expect(getProjectsCacheKey(instance, {organizationId: 'org123'})).toBe(
111
+ 'projects:org:org123:features',
112
+ )
113
+ })
114
+
115
+ it('appends :members when includeMembers is true', () => {
116
+ expect(getProjectsCacheKey(instance, {includeMembers: true})).toBe('projects:members:features')
117
+ })
118
+
119
+ it('combines all segments in order', () => {
120
+ expect(
121
+ getProjectsCacheKey(instance, {
122
+ organizationId: 'org123',
123
+ includeMembers: true,
124
+ includeFeatures: true,
125
+ onlyExplicitMembership: true,
126
+ }),
127
+ ).toBe('projects:org:org123:members:features:explicit')
128
+ })
129
+
130
+ it('produces distinct keys for each meaningful option permutation', () => {
131
+ const keys = new Set([
132
+ getProjectsCacheKey(instance),
133
+ getProjectsCacheKey(instance, {includeMembers: true}),
134
+ getProjectsCacheKey(instance, {includeFeatures: false}),
135
+ getProjectsCacheKey(instance, {includeMembers: true, includeFeatures: false}),
136
+ getProjectsCacheKey(instance, {onlyExplicitMembership: true}),
137
+ getProjectsCacheKey(instance, {organizationId: 'a'}),
138
+ getProjectsCacheKey(instance, {organizationId: 'b'}),
139
+ ])
140
+ expect(keys.size).toBe(7)
75
141
  })
76
142
  })
@@ -1,35 +1,71 @@
1
1
  import {switchMap} from 'rxjs'
2
2
 
3
3
  import {getClientState} from '../client/clientStore'
4
- import {createFetcherStore} from '../utils/createFetcherStore'
4
+ import {type Project} from '../project/project'
5
+ import {type SanityInstance} from '../store/createSanityInstance'
6
+ import {defineFetcher} from '../store/fetcherStore'
7
+ import {buildQuery} from '../utils/buildQuery'
5
8
 
6
9
  const API_VERSION = 'v2025-02-19'
7
10
 
8
- const projects = createFetcherStore({
9
- name: 'Projects',
10
- getKey: (_instance, options?: {organizationId?: string; includeMembers?: boolean}) => {
11
- const orgKey = options?.organizationId ? `:org:${options.organizationId}` : ''
12
- const membersKey = options?.includeMembers === false ? ':no-members' : ''
13
- return `projects${orgKey}${membersKey}`
14
- },
15
- fetcher: (instance) => (options?: {organizationId?: string; includeMembers?: boolean}) =>
16
- getClientState(instance, {
17
- apiVersion: API_VERSION,
18
- scope: 'global',
19
- requestTagPrefix: 'sanity.sdk.projects',
20
- }).observable.pipe(
21
- switchMap((client) => {
22
- const organizationId = options?.organizationId
23
- return client.observable.projects.list({
24
- // client method has a type that expects false | undefined
25
- includeMembers: !options?.includeMembers ? false : undefined,
26
- organizationId,
27
- })
28
- }),
11
+ /** @public */
12
+ export interface ProjectsOptions<
13
+ IncludeMembers extends boolean = false,
14
+ IncludeFeatures extends boolean = true,
15
+ > {
16
+ organizationId?: string
17
+ includeMembers?: IncludeMembers
18
+ includeFeatures?: IncludeFeatures
19
+ onlyExplicitMembership?: boolean
20
+ }
21
+
22
+ function normalizeProjectsOptions(options?: ProjectsOptions<boolean, boolean>) {
23
+ return {
24
+ organizationId: options?.organizationId,
25
+ includeMembers: options?.includeMembers ?? false,
26
+ includeFeatures: options?.includeFeatures ?? true,
27
+ onlyExplicitMembership: options?.onlyExplicitMembership ?? false,
28
+ }
29
+ }
30
+
31
+ /** @internal */
32
+ export function getProjectsCacheKey(
33
+ _instance: SanityInstance,
34
+ options?: ProjectsOptions<boolean, boolean>,
35
+ ): string {
36
+ const {organizationId, includeMembers, includeFeatures, onlyExplicitMembership} =
37
+ normalizeProjectsOptions(options)
38
+ const orgKey = organizationId ? `:org:${organizationId}` : ''
39
+ const membersKey = includeMembers ? ':members' : ''
40
+ const featuresKey = includeFeatures ? ':features' : ''
41
+ const explicitKey = onlyExplicitMembership ? ':explicit' : ''
42
+ return `projects${orgKey}${membersKey}${featuresKey}${explicitKey}`
43
+ }
44
+
45
+ /**
46
+ * Fetcher for the current user's projects (`GET /projects`), on the shared
47
+ * fetcher cache.
48
+ *
49
+ * @internal
50
+ */
51
+ export const projects = defineFetcher<
52
+ [options?: ProjectsOptions<boolean, boolean>],
53
+ Project<boolean, boolean>[]
54
+ >({
55
+ name: 'projects',
56
+ getKey: getProjectsCacheKey,
57
+ fetch: (instance) => (options) =>
58
+ getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
59
+ switchMap((client) =>
60
+ client.observable.request<Project<boolean, boolean>[]>({
61
+ uri: '/projects',
62
+ query: buildQuery(normalizeProjectsOptions(options)),
63
+ tag: 'projects.list',
64
+ }),
65
+ ),
29
66
  ),
67
+ tags: (data) => [
68
+ {type: 'project', id: 'LIST'},
69
+ ...data.map((project) => ({type: 'project', id: project.id})),
70
+ ],
30
71
  })
31
-
32
- /** @public */
33
- export const getProjectsState = projects.getState
34
- /** @public */
35
- export const resolveProjects = projects.resolveState