@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,33 +1,178 @@
1
1
  import {type SanityClient} from '@sanity/client'
2
2
  import {of} from 'rxjs'
3
- import {describe, it} from 'vitest'
3
+ import {afterEach, beforeEach, describe, it} from 'vitest'
4
4
 
5
5
  import {getClientState} from '../client/clientStore'
6
- import {createSanityInstance} from '../store/createSanityInstance'
6
+ import {projects} from '../projects/projects'
7
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
8
  import {type StateSource} from '../store/createStateSourceAction'
8
- import {resolveProject} from './project'
9
+ import {getProjectCacheKey, project} from './project'
9
10
 
10
11
  vi.mock('../client/clientStore')
11
12
 
12
13
  describe('project', () => {
13
- it('calls the `client.observable.projects.getById` method on the client and returns the result', async () => {
14
- const instance = createSanityInstance()
14
+ let instance: SanityInstance
15
15
 
16
- const project = {id: 'a'}
17
- const getById = vi.fn().mockReturnValue(of(project))
16
+ beforeEach(() => {
17
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
18
+ })
19
+
20
+ afterEach(() => {
21
+ instance.dispose()
22
+ })
23
+
24
+ it('calls `client.observable.request` against `/projects/<id>` and returns the result', async () => {
25
+ const detail = {id: 'a'}
26
+ const request = vi.fn().mockReturnValue(of(detail))
18
27
 
19
28
  const mockClient = {
20
- observable: {
21
- projects: {getById} as unknown as SanityClient['observable']['projects'],
29
+ observable: {request} as unknown as SanityClient['observable'],
30
+ } as SanityClient
31
+
32
+ vi.mocked(getClientState).mockReturnValue({
33
+ observable: of(mockClient),
34
+ } as StateSource<SanityClient>)
35
+
36
+ const result = await project.resolveState(instance, {projectId: 'a'})
37
+ expect(result).toEqual(detail)
38
+ expect(request).toHaveBeenCalledWith({
39
+ uri: '/projects/a',
40
+ query: {includeMembers: 'true', includeFeatures: 'true'},
41
+ tag: 'projects.get',
42
+ })
43
+ })
44
+
45
+ it('serializes query params (booleans → strings) and respects flags', async () => {
46
+ const request = vi.fn().mockReturnValue(of({id: 'a'}))
47
+ const mockClient = {
48
+ observable: {request} as unknown as SanityClient['observable'],
49
+ } as SanityClient
50
+
51
+ vi.mocked(getClientState).mockReturnValue({
52
+ observable: of(mockClient),
53
+ } as StateSource<SanityClient>)
54
+
55
+ await project.resolveState(instance, {
56
+ projectId: 'a',
57
+ includeMembers: false,
58
+ includeFeatures: false,
59
+ })
60
+
61
+ expect(request).toHaveBeenCalledWith({
62
+ uri: '/projects/a',
63
+ query: {
64
+ includeMembers: 'false',
65
+ includeFeatures: 'false',
22
66
  },
67
+ tag: 'projects.get',
68
+ })
69
+ })
70
+
71
+ it('falls back to the instance projectId when none is provided in options', async () => {
72
+ const request = vi.fn().mockReturnValue(of({id: 'p'}))
73
+ const mockClient = {
74
+ observable: {request} as unknown as SanityClient['observable'],
75
+ } as SanityClient
76
+
77
+ vi.mocked(getClientState).mockReturnValue({
78
+ observable: of(mockClient),
79
+ } as StateSource<SanityClient>)
80
+
81
+ await project.resolveState(instance)
82
+ expect(request).toHaveBeenCalledWith({
83
+ uri: '/projects/p',
84
+ query: {includeMembers: 'true', includeFeatures: 'true'},
85
+ tag: 'projects.get',
86
+ })
87
+ })
88
+
89
+ it('pre-seeds a memberless detail read from the cached default list without fetching', async () => {
90
+ const request = vi.fn().mockReturnValue(
91
+ of([
92
+ {id: 'p1', displayName: 'One'},
93
+ {id: 'p2', displayName: 'Two'},
94
+ ]),
95
+ )
96
+ const mockClient = {
97
+ observable: {request} as unknown as SanityClient['observable'],
23
98
  } as SanityClient
24
99
 
25
100
  vi.mocked(getClientState).mockReturnValue({
26
101
  observable: of(mockClient),
27
102
  } as StateSource<SanityClient>)
28
103
 
29
- const result = await resolveProject(instance, {projectId: 'a'})
30
- expect(result).toEqual(project)
31
- expect(getById).toHaveBeenCalledWith('a')
104
+ await projects.resolveState(instance)
105
+ expect(request).toHaveBeenCalledTimes(1)
106
+
107
+ const source = project.getState(instance, {projectId: 'p1', includeMembers: false})
108
+ const unsubscribe = source.subscribe()
109
+ await new Promise((resolve) => setTimeout(resolve, 0))
110
+
111
+ expect(source.getCurrent()).toMatchObject({status: 'success', data: {id: 'p1'}})
112
+ expect(request).toHaveBeenCalledTimes(1)
113
+ unsubscribe()
114
+ })
115
+
116
+ it('does not pre-seed a detail read that includes members', async () => {
117
+ const list = [{id: 'p1', displayName: 'One'}]
118
+ const detail = {id: 'p1', displayName: 'One', members: []}
119
+ const request = vi.fn().mockReturnValueOnce(of(list)).mockReturnValueOnce(of(detail))
120
+ const mockClient = {
121
+ observable: {request} as unknown as SanityClient['observable'],
122
+ } as SanityClient
123
+
124
+ vi.mocked(getClientState).mockReturnValue({
125
+ observable: of(mockClient),
126
+ } as StateSource<SanityClient>)
127
+
128
+ await projects.resolveState(instance)
129
+
130
+ const result = await project.resolveState(instance, {projectId: 'p1'})
131
+ expect(result).toEqual(detail)
132
+ expect(request).toHaveBeenCalledTimes(2)
133
+ })
134
+ })
135
+
136
+ describe('project cache key generation', () => {
137
+ const mockInstance = {config: {projectId: 'p'}} as SanityInstance
138
+
139
+ it('default call includes :members and :features (both default-true)', () => {
140
+ expect(getProjectCacheKey(mockInstance)).toBe('project:p:members:features')
141
+ })
142
+
143
+ it('treats undefined and the matching default as the same key', () => {
144
+ expect(getProjectCacheKey(mockInstance)).toBe(
145
+ getProjectCacheKey(mockInstance, {includeMembers: true, includeFeatures: true}),
146
+ )
147
+ })
148
+
149
+ it('explicit includeFeatures: false drops the :features segment', () => {
150
+ expect(getProjectCacheKey(mockInstance, {includeFeatures: false})).toBe('project:p:members')
151
+ })
152
+
153
+ it('explicit includeMembers: false drops the :members segment', () => {
154
+ expect(getProjectCacheKey(mockInstance, {includeMembers: false})).toBe('project:p:features')
155
+ })
156
+
157
+ it('combines all segments in order', () => {
158
+ expect(
159
+ getProjectCacheKey(mockInstance, {
160
+ projectId: 'a',
161
+ includeMembers: true,
162
+ includeFeatures: true,
163
+ }),
164
+ ).toBe('project:a:members:features')
165
+ })
166
+
167
+ it('produces distinct keys for each meaningful option permutation', () => {
168
+ const keys = new Set([
169
+ getProjectCacheKey(mockInstance),
170
+ getProjectCacheKey(mockInstance, {includeMembers: false}),
171
+ getProjectCacheKey(mockInstance, {includeFeatures: false}),
172
+ getProjectCacheKey(mockInstance, {includeMembers: false, includeFeatures: false}),
173
+ getProjectCacheKey(mockInstance, {projectId: 'a'}),
174
+ getProjectCacheKey(mockInstance, {projectId: 'b'}),
175
+ ])
176
+ expect(keys.size).toBe(6)
32
177
  })
33
178
  })
@@ -1,35 +1,148 @@
1
1
  import {switchMap} from 'rxjs'
2
2
 
3
3
  import {getClientState} from '../client/clientStore'
4
- import {createFetcherStore} from '../utils/createFetcherStore'
4
+ import {type ProjectHandle} from '../config/sanityConfig'
5
+ import {projects} from '../projects/projects'
6
+ import {type SanityInstance} from '../store/createSanityInstance'
7
+ import {defineFetcher} from '../store/fetcherStore'
8
+ import {buildQuery} from '../utils/buildQuery'
5
9
 
6
10
  const API_VERSION = 'v2025-02-19'
7
11
 
8
12
  /** @public */
9
- export type ProjectHandle = {
10
- projectId: string
11
- }
12
- const project = createFetcherStore({
13
- name: 'Project',
14
- getKey: (_instance, options: ProjectHandle) => {
15
- const projectId = options?.projectId
16
- if (!projectId) {
17
- throw new Error('A projectId is required to use the project API.')
18
- }
19
- return projectId
20
- },
21
- fetcher: (instance) => (options: ProjectHandle) => {
22
- const projectId = options.projectId
23
-
24
- return getClientState(instance, {
25
- apiVersion: API_VERSION,
26
- scope: 'global',
27
- projectId,
28
- }).observable.pipe(switchMap((client) => client.observable.projects.getById(projectId!)))
29
- },
30
- })
13
+ export interface ProjectMemberRole {
14
+ name: string
15
+ title: string
16
+ description: string
17
+ }
31
18
 
32
19
  /** @public */
33
- export const getProjectState = project.getState
20
+ export interface ProjectMember {
21
+ id: string
22
+ createdAt: string
23
+ updatedAt: string
24
+ isCurrentUser: boolean
25
+ isRobot: boolean
26
+ roles: ProjectMemberRole[]
27
+ }
28
+
29
+ /** @public */
30
+ export interface ProjectMetadata {
31
+ color?: string
32
+ externalStudioHost?: string
33
+ initialTemplate?: string
34
+ cliInitializedAt?: string
35
+ integration: 'manage' | 'cli'
36
+ }
37
+
38
+ /**
39
+ * The base fields returned from `/projects` for every project.
40
+ * @public
41
+ */
42
+ export interface ProjectBase {
43
+ id: string
44
+ displayName: string
45
+ studioHost: string | null
46
+ organizationId: string
47
+ metadata: ProjectMetadata
48
+ isBlocked: boolean
49
+ isDisabled: boolean
50
+ isDisabledByUser: boolean
51
+ activityFeedEnabled: boolean
52
+ createdAt: string
53
+ updatedAt: string
54
+ }
55
+
56
+ /**
57
+ * A `Project` with `members` and/or `features` conditionally included
58
+ * based on the query options used to fetch it.
59
+ * @public
60
+ */
61
+ export type Project<
62
+ IncludeMembers extends boolean = true,
63
+ IncludeFeatures extends boolean = true,
64
+ > = ProjectBase &
65
+ // `boolean extends T` is non-distributive — true only when T is the wide
66
+ // `boolean`, in which case the field is optional. Literal `true`/`false`
67
+ // fall through to the strict branch.
68
+ (boolean extends IncludeMembers
69
+ ? {members?: ProjectMember[]}
70
+ : IncludeMembers extends true
71
+ ? {members: ProjectMember[]}
72
+ : unknown) &
73
+ (boolean extends IncludeFeatures
74
+ ? {features?: string[]}
75
+ : IncludeFeatures extends true
76
+ ? {features: string[]}
77
+ : unknown)
78
+
34
79
  /** @public */
35
- export const resolveProject = project.resolveState
80
+ export interface ProjectOptions<
81
+ IncludeMembers extends boolean = true,
82
+ IncludeFeatures extends boolean = true,
83
+ > extends ProjectHandle {
84
+ includeMembers?: IncludeMembers
85
+ includeFeatures?: IncludeFeatures
86
+ }
87
+
88
+ function normalizeProjectOptions(options?: ProjectOptions<boolean, boolean>) {
89
+ return {
90
+ includeMembers: options?.includeMembers ?? true,
91
+ includeFeatures: options?.includeFeatures ?? true,
92
+ }
93
+ }
94
+
95
+ function resolveProjectId(instance: SanityInstance, options?: ProjectOptions<boolean, boolean>) {
96
+ const projectId = options?.projectId ?? instance.config.projectId
97
+ if (!projectId) {
98
+ throw new Error('A projectId is required to use the project API.')
99
+ }
100
+ return projectId
101
+ }
102
+
103
+ /** @internal */
104
+ export function getProjectCacheKey(
105
+ instance: SanityInstance,
106
+ options?: ProjectOptions<boolean, boolean>,
107
+ ): string {
108
+ const projectId = resolveProjectId(instance, options)
109
+ const {includeMembers, includeFeatures} = normalizeProjectOptions(options)
110
+ const membersKey = includeMembers ? ':members' : ''
111
+ const featuresKey = includeFeatures ? ':features' : ''
112
+ return `project:${projectId}${membersKey}${featuresKey}`
113
+ }
114
+
115
+ /**
116
+ * Fetcher for a single project (`GET /projects/:id`), on the shared fetcher
117
+ * cache. Pre-seeds from the default {@link projects} list entry when it can
118
+ * satisfy the read — the default list carries features but not members, so
119
+ * only `includeMembers: false` reads seed.
120
+ *
121
+ * @internal
122
+ */
123
+ export const project = defineFetcher<
124
+ [options?: ProjectOptions<boolean, boolean>],
125
+ Project<boolean, boolean>
126
+ >({
127
+ name: 'project',
128
+ getKey: getProjectCacheKey,
129
+ fetch: (instance) => (options) =>
130
+ getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
131
+ switchMap((client) =>
132
+ client.observable.request<Project<boolean, boolean>>({
133
+ uri: `/projects/${resolveProjectId(instance, options)}`,
134
+ query: buildQuery(normalizeProjectOptions(options)),
135
+ tag: 'projects.get',
136
+ }),
137
+ ),
138
+ ),
139
+ tags: (data) => [{type: 'project', id: data.id}],
140
+ initialData: (instance, options) => {
141
+ if (normalizeProjectOptions(options).includeMembers) return undefined
142
+ const snapshot = projects.getState(instance).getCurrent()
143
+ if (snapshot.status !== 'success') return undefined
144
+ const projectId = resolveProjectId(instance, options)
145
+ const match = snapshot.data.find((item) => item.id === projectId)
146
+ return match && {data: match, dataUpdatedAt: snapshot.dataUpdatedAt}
147
+ },
148
+ })
@@ -1,7 +1,6 @@
1
1
  import {NEVER} from 'rxjs'
2
2
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
3
 
4
- import {type DocumentResource} from '../config/sanityConfig'
5
4
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
6
5
  import {type StoreState} from '../store/createStoreState'
7
6
  import {hashString} from '../utils/hashString'
@@ -31,8 +30,7 @@ vi.mock('./subscribeToStateAndFetchBatches.ts')
31
30
 
32
31
  describe('getProjectionState', () => {
33
32
  let instance: SanityInstance
34
- const resource: DocumentResource = {projectId: 'p', dataset: 'd'}
35
- const docHandle = {documentId: 'exampleId', documentType: 'exampleType', resource}
33
+ const docHandle = {documentId: 'exampleId', documentType: 'exampleType'}
36
34
  const projection1 = '{exampleProjection1}'
37
35
  const hash1 = hashString(projection1)
38
36
  const projection2 = '{exampleProjection2}'
@@ -50,7 +48,7 @@ describe('getProjectionState', () => {
50
48
  return NEVER.subscribe()
51
49
  })
52
50
 
53
- instance = createSanityInstance()
51
+ instance = createSanityInstance({projectId: 'exampleProject', dataset: 'exampleDataset'})
54
52
  vi.useFakeTimers() // Enable fake timers for each test
55
53
  })
56
54
 
@@ -128,7 +126,7 @@ describe('getProjectionState', () => {
128
126
  expect(state.get().subscriptions).toEqual({})
129
127
  expect(state.get().documentProjections).toEqual({})
130
128
 
131
- const unsubscribe1 = projectionState1.subscribe(vi.fn()) // Should use ID 3
129
+ const unsubscribe1 = projectionState1.subscribe(vi.fn()) // Should use ID 2
132
130
  expect(state.get().subscriptions).toEqual({
133
131
  [docHandle.documentId]: {[hash1]: {testSubId_2: true}},
134
132
  })
@@ -136,7 +134,7 @@ describe('getProjectionState', () => {
136
134
  [docHandle.documentId]: {[hash1]: projection1},
137
135
  })
138
136
 
139
- const unsubscribe2 = projectionState2.subscribe(vi.fn()) // Should use ID 4
137
+ const unsubscribe2 = projectionState2.subscribe(vi.fn()) // Should use ID 3
140
138
  expect(state.get().subscriptions).toEqual({
141
139
  [docHandle.documentId]: {
142
140
  [hash1]: {testSubId_2: true},
@@ -150,10 +148,12 @@ describe('getProjectionState', () => {
150
148
  },
151
149
  })
152
150
 
153
- const unsubscribe3 = projectionState1.subscribe(vi.fn()) // Should use ID 5
151
+ // should share ID 2 from the first subscription -- we only care if ANY subscription exists
152
+ // so we can include the document / projection in a bulk fetch
153
+ const unsubscribe3 = projectionState1.subscribe(vi.fn())
154
154
  expect(state.get().subscriptions).toEqual({
155
155
  [docHandle.documentId]: {
156
- [hash1]: {testSubId_2: true, testSubId_4: true},
156
+ [hash1]: {testSubId_2: true},
157
157
  [hash2]: {testSubId_3: true},
158
158
  },
159
159
  })
@@ -167,23 +167,20 @@ describe('getProjectionState', () => {
167
167
  })
168
168
 
169
169
  // --- Test Unsubscribe ---
170
- unsubscribe1() // Unsubscribes ID 3
171
- expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toEqual({
172
- testSubId_2: true,
173
- testSubId_4: true,
174
- })
170
+ unsubscribe1() // Unsubscribes first subscription consumer
171
+ expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toEqual({testSubId_2: true})
175
172
  vi.advanceTimersByTime(PROJECTION_STATE_CLEAR_DELAY)
176
- expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toEqual({testSubId_4: true})
173
+ expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toEqual({testSubId_2: true})
177
174
  expect(state.get().documentProjections[docHandle.documentId]?.[hash1]).toEqual(projection1)
178
175
 
179
- unsubscribe3() // Unsubscribes ID 5
176
+ unsubscribe3() // Also unsubscibes first projection subscription, should be removed
180
177
  vi.advanceTimersByTime(PROJECTION_STATE_CLEAR_DELAY)
181
178
  expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toBeUndefined()
182
179
  expect(state.get().documentProjections[docHandle.documentId]?.[hash1]).toBeUndefined()
183
180
  expect(state.get().subscriptions[docHandle.documentId]?.[hash2]).toEqual({testSubId_3: true})
184
181
  expect(state.get().documentProjections[docHandle.documentId]?.[hash2]).toEqual(projection2)
185
182
 
186
- unsubscribe2() // Unsubscribes ID 4
183
+ unsubscribe2() // Unsubscribes second projection subscription, should be removed
187
184
  vi.advanceTimersByTime(PROJECTION_STATE_CLEAR_DELAY)
188
185
  expect(state.get().subscriptions[docHandle.documentId]).toBeUndefined()
189
186
  expect(state.get().documentProjections[docHandle.documentId]).toBeUndefined()
@@ -205,14 +202,14 @@ describe('getProjectionState', () => {
205
202
  }))
206
203
 
207
204
  const unsubscribe1 = projectionState.subscribe(vi.fn()) // Should use ID 2
208
- const unsubscribe2 = projectionState.subscribe(vi.fn()) // Should use ID 3
205
+ const unsubscribe2 = projectionState.subscribe(vi.fn()) // Should reuse ID 2
209
206
 
210
207
  expect(state.get().values[docHandle.documentId]?.[hash]).toEqual({
211
208
  data: initialData,
212
209
  isPending: true,
213
210
  })
214
211
 
215
- unsubscribe1() // Unsubscribes ID 2
212
+ unsubscribe1() // Unsubscribes first subscription consumer
216
213
  vi.advanceTimersByTime(PROJECTION_STATE_CLEAR_DELAY)
217
214
  expect(state.get().values[docHandle.documentId]?.[hash]).toEqual({
218
215
  data: initialData,
@@ -221,9 +218,10 @@ describe('getProjectionState', () => {
221
218
  expect(Object.keys(state.get().subscriptions[docHandle.documentId]?.[hash] ?? {}).length).toBe(
222
219
  1,
223
220
  )
224
- expect(state.get().subscriptions[docHandle.documentId]?.[hash]).toEqual({testSubId_3: true})
221
+ // should still have the same subscription ID since we're not unsubscribing the second consumer
222
+ expect(state.get().subscriptions[docHandle.documentId]?.[hash]).toEqual({testSubId_2: true})
225
223
 
226
- unsubscribe2() // Unsubscribes ID 3
224
+ unsubscribe2() // Unsubscribes second subscription consumer
227
225
  expect(state.get().values[docHandle.documentId]?.[hash]).toEqual({
228
226
  data: initialData,
229
227
  isPending: true,
@@ -1,5 +1,5 @@
1
1
  import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
- import {omit} from 'lodash-es'
2
+ import {type SanityProjectionResult} from 'groq'
3
3
 
4
4
  import {type DocumentHandle} from '../config/sanityConfig'
5
5
  import {bindActionByResourceAndPerspective} from '../store/createActionBinder'
@@ -11,6 +11,8 @@ import {
11
11
  } from '../store/createStateSourceAction'
12
12
  import {hashString} from '../utils/hashString'
13
13
  import {insecureRandomId} from '../utils/ids'
14
+ import {omitProperty} from '../utils/object'
15
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
14
16
  import {projectionStore} from './projectionStore'
15
17
  import {type ProjectionStoreState, type ProjectionValuePending} from './types'
16
18
  import {PROJECTION_STATE_CLEAR_DELAY, STABLE_EMPTY_PROJECTION, validateProjection} from './util'
@@ -24,6 +26,24 @@ export interface ProjectionOptions<
24
26
  projection: TProjection
25
27
  }
26
28
 
29
+ /**
30
+ * @beta
31
+ */
32
+ export function getProjectionState<
33
+ TProjection extends string = string,
34
+ TDocumentType extends string = string,
35
+ TDataset extends string = string,
36
+ TProjectId extends string = string,
37
+ >(
38
+ instance: SanityInstance,
39
+ options: ProjectionOptions<TProjection, TDocumentType, TDataset, TProjectId>,
40
+ ): StateSource<
41
+ | ProjectionValuePending<
42
+ SanityProjectionResult<TProjection, TDocumentType, `${TProjectId}.${TDataset}`>
43
+ >
44
+ | undefined
45
+ >
46
+
27
47
  /**
28
48
  * @beta
29
49
  */
@@ -91,9 +111,9 @@ export const _getProjectionState = bindActionByResourceAndPerspective(
91
111
  }))
92
112
 
93
113
  return () => {
94
- setTimeout(() => {
114
+ setCleanupTimeout(() => {
95
115
  state.set('removeSubscription', (prev): Partial<ProjectionStoreState> => {
96
- const documentSubscriptionsForHash = omit(
116
+ const documentSubscriptionsForHash = omitProperty(
97
117
  prev.subscriptions[documentId]?.[projectionHash],
98
118
  subscriptionId,
99
119
  )
@@ -1,7 +1,7 @@
1
- import {DocumentId} from '@sanity/id-utils'
1
+ import {type ClientPerspective} from '@sanity/client'
2
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
3
 
3
- import {type PerspectiveHandle} from '../config/sanityConfig'
4
- import {getPublishedId} from '../utils/ids'
4
+ import {type ReleasePerspective} from '../config/sanityConfig'
5
5
  import {
6
6
  type DocumentProjections,
7
7
  type DocumentProjectionValues,
@@ -67,7 +67,7 @@ interface ProcessProjectionQueryOptions {
67
67
  ids: Set<string>
68
68
  results: ProjectionQueryResult[]
69
69
  documentStatuses?: ProjectionStoreState['documentStatuses']
70
- perspective: NonNullable<PerspectiveHandle['perspective']>
70
+ perspective: ClientPerspective | ReleasePerspective
71
71
  }
72
72
 
73
73
  export function processProjectionQuery({
@@ -84,7 +84,7 @@ export function processProjectionQuery({
84
84
  } = {}
85
85
 
86
86
  for (const result of results) {
87
- const originalId = getPublishedId(result._id)
87
+ const originalId = getPublishedId(DocumentId(result._id))
88
88
  const hash = result.__projectionHash
89
89
 
90
90
  if (!ids.has(originalId)) continue
@@ -24,7 +24,7 @@ describe('projectionStore', () => {
24
24
  new Observable(subscriber).subscribe(),
25
25
  )
26
26
 
27
- const instance = createSanityInstance()
27
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
28
28
 
29
29
  const {state, dispose} = createStoreInstance(
30
30
  instance,
@@ -23,7 +23,7 @@ describe('resolveProjection', () => {
23
23
  } as ProjectionValuePending<Record<string, unknown>>),
24
24
  } as StateSource<ProjectionValuePending<Record<string, unknown>>>)
25
25
 
26
- instance = createSanityInstance()
26
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
27
27
  })
28
28
 
29
29
  afterEach(() => {
@@ -34,7 +34,6 @@ describe('resolveProjection', () => {
34
34
  const docHandle = createDocumentHandle({
35
35
  documentId: 'doc123',
36
36
  documentType: 'movie',
37
- resource: {projectId: 'p', dataset: 'd'},
38
37
  })
39
38
  const projection = '{title}'
40
39
 
@@ -1,3 +1,4 @@
1
+ import {type SanityProjectionResult} from 'groq'
1
2
  import {filter, firstValueFrom} from 'rxjs'
2
3
 
3
4
  import {bindActionByResourceAndPerspective} from '../store/createActionBinder'
@@ -6,6 +7,21 @@ import {getProjectionState, type ProjectionOptions} from './getProjectionState'
6
7
  import {projectionStore} from './projectionStore'
7
8
  import {type ProjectionValuePending} from './types'
8
9
 
10
+ /** @beta */
11
+ export function resolveProjection<
12
+ TProjection extends string = string,
13
+ TDocumentType extends string = string,
14
+ TDataset extends string = string,
15
+ TProjectId extends string = string,
16
+ >(
17
+ instance: SanityInstance,
18
+ options: ProjectionOptions<TProjection, TDocumentType, TDataset, TProjectId>,
19
+ ): Promise<
20
+ ProjectionValuePending<
21
+ SanityProjectionResult<TProjection, TDocumentType, `${TProjectId}.${TDataset}`>
22
+ >
23
+ >
24
+
9
25
  /** @beta */
10
26
  export function resolveProjection<TData extends object>(
11
27
  instance: SanityInstance,
@@ -23,7 +23,7 @@ describe('subscribeToStateAndFetchBatches', () => {
23
23
 
24
24
  beforeEach(() => {
25
25
  vi.clearAllMocks()
26
- instance = createSanityInstance()
26
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
27
27
  state = createStoreState<ProjectionStoreState>({
28
28
  documentProjections: {},
29
29
  documentStatuses: {},