@sanity/sdk 2.18.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 (53) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +233 -12
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
  3. package/dist/_chunks-es/_internal.js +1 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -1
  5. package/dist/_chunks-es/createGroqSearchFilter.js +284 -6
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  7. package/dist/_chunks-es/telemetryManager.js +2 -2
  8. package/dist/_exports/_internal.d.ts +2 -2
  9. package/dist/_exports/_internal.d.ts.map +1 -1
  10. package/dist/_exports/_internal.js +3 -1
  11. package/dist/index.d.ts +599 -185
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +492 -337
  14. package/dist/index.js.map +1 -1
  15. package/package.json +7 -7
  16. package/src/_exports/_internal.ts +11 -0
  17. package/src/_exports/index.ts +57 -12
  18. package/src/access/checkPermissions.test.ts +55 -0
  19. package/src/access/checkPermissions.ts +54 -0
  20. package/src/applications/applications.test-d.ts +83 -0
  21. package/src/applications/applications.test.ts +130 -0
  22. package/src/applications/applications.ts +364 -0
  23. package/src/applications/userApplications.test.ts +126 -0
  24. package/src/applications/userApplications.ts +261 -0
  25. package/src/auth/getOrganizationVerificationState.test.ts +7 -7
  26. package/src/auth/getOrganizationVerificationState.ts +4 -4
  27. package/src/document/documentStore.concurrency.test.ts +313 -22
  28. package/src/installations/installations.test-d.ts +68 -0
  29. package/src/installations/installations.test.ts +62 -0
  30. package/src/installations/installations.ts +202 -0
  31. package/src/organization/organization.test-d.ts +18 -84
  32. package/src/organization/organization.test.ts +11 -11
  33. package/src/organization/organization.ts +26 -54
  34. package/src/organizations/organizations.test-d.ts +11 -32
  35. package/src/organizations/organizations.test.ts +8 -8
  36. package/src/organizations/organizations.ts +29 -54
  37. package/src/project/project.test-d.ts +19 -75
  38. package/src/project/project.test.ts +57 -10
  39. package/src/project/project.ts +33 -52
  40. package/src/projects/projects.test-d.ts +15 -21
  41. package/src/projects/projects.test.ts +8 -8
  42. package/src/projects/projects.ts +25 -49
  43. package/src/store/fetcherStore.test-d.ts +121 -0
  44. package/src/store/fetcherStore.test.ts +299 -0
  45. package/src/store/fetcherStore.ts +801 -0
  46. package/src/utils/buildQuery.test.ts +14 -0
  47. package/src/utils/buildQuery.ts +17 -0
  48. package/src/utils/includeShape.ts +36 -0
  49. package/src/utils/setCleanupTimeout.ts +21 -8
  50. package/dist/_chunks-es/version.js +0 -7
  51. package/dist/_chunks-es/version.js.map +0 -1
  52. package/src/utils/defineIntent.test.ts +0 -477
  53. package/src/utils/defineIntent.ts +0 -244
@@ -7,8 +7,8 @@ import {
7
7
  type OrganizationOptions,
8
8
  } from '../organization/organization'
9
9
  import {type SanityInstance} from '../store/createSanityInstance'
10
- import {type StateSource} from '../store/createStateSourceAction'
11
- import {createFetcherStore} from '../utils/createFetcherStore'
10
+ import {defineFetcher} from '../store/fetcherStore'
11
+ import {buildQuery} from '../utils/buildQuery'
12
12
 
13
13
  const API_VERSION = 'v2025-02-19'
14
14
 
@@ -57,7 +57,7 @@ export interface OrganizationsOptions<
57
57
  includeImplicitMemberships?: boolean
58
58
  }
59
59
 
60
- function normalizeOrganizationOptions(options?: OrganizationsOptions<boolean, boolean>) {
60
+ function normalizeOrganizationsOptions(options?: OrganizationsOptions<boolean, boolean>) {
61
61
  return {
62
62
  includeImplicitMemberships: options?.includeImplicitMemberships ?? false,
63
63
  includeMembers: options?.includeMembers ?? false,
@@ -71,62 +71,37 @@ export function getOrganizationsCacheKey(
71
71
  options?: OrganizationsOptions<boolean, boolean>,
72
72
  ): string {
73
73
  const {includeMembers, includeFeatures, includeImplicitMemberships} =
74
- normalizeOrganizationOptions(options)
74
+ normalizeOrganizationsOptions(options)
75
75
  const membersKey = includeMembers ? ':members' : ''
76
76
  const featuresKey = includeFeatures ? ':features' : ''
77
77
  const implicitKey = includeImplicitMemberships ? ':implicit' : ''
78
78
  return `organizations${membersKey}${featuresKey}${implicitKey}`
79
79
  }
80
80
 
81
- const organizations = createFetcherStore({
82
- name: 'Organizations',
83
- getKey: getOrganizationsCacheKey,
84
- fetcher: (instance) => (options?: OrganizationsOptions<boolean, boolean>) => {
85
- return getClientState(instance, {
86
- apiVersion: API_VERSION,
87
- scope: 'global',
88
- }).observable.pipe(
89
- switchMap((client) => {
90
- const normalized = normalizeOrganizationOptions(options)
91
- const query = Object.fromEntries(
92
- Object.entries(normalized)
93
- .filter(([, value]) => value !== undefined)
94
- .map(([key, value]) => [key, String(value)]),
95
- )
96
-
97
- return client.observable.request({
98
- uri: `/organizations`,
99
- query,
100
- tag: 'organizations.get',
101
- })
102
- }),
103
- )
104
- },
105
- })
106
-
107
81
  /**
108
- * Public signature for the organization state source. The conditional generics
109
- * cannot flow through `BoundStoreAction`, so we declare the signature here
110
- * and assign the (already-correct) runtime function to it.
82
+ * Fetcher for the current user's organizations (`GET /organizations`), on the
83
+ * shared fetcher cache.
84
+ *
85
+ * @internal
111
86
  */
112
- type GetOrganizationsState = <
113
- IncludeMembers extends boolean = false,
114
- IncludeFeatures extends boolean = false,
115
- >(
116
- instance: SanityInstance,
117
- options?: OrganizationsOptions<IncludeMembers, IncludeFeatures>,
118
- ) => StateSource<Organizations<IncludeMembers, IncludeFeatures> | undefined>
119
-
120
- type ResolveOrganizations = <
121
- IncludeMembers extends boolean = false,
122
- IncludeFeatures extends boolean = false,
123
- >(
124
- instance: SanityInstance,
125
- options?: OrganizationsOptions<IncludeMembers, IncludeFeatures>,
126
- ) => Promise<Organizations<IncludeMembers, IncludeFeatures>>
127
-
128
- /** @public */
129
- export const getOrganizationsState: GetOrganizationsState = organizations.getState
130
-
131
- /** @public */
132
- export const resolveOrganizations: ResolveOrganizations = organizations.resolveState
87
+ export const organizations = defineFetcher<
88
+ [options?: OrganizationsOptions<boolean, boolean>],
89
+ Organizations<boolean, boolean>
90
+ >({
91
+ name: 'organizations',
92
+ getKey: getOrganizationsCacheKey,
93
+ fetch: (instance) => (options) =>
94
+ getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
95
+ switchMap((client) =>
96
+ client.observable.request<Organizations<boolean, boolean>>({
97
+ uri: '/organizations',
98
+ query: buildQuery(normalizeOrganizationsOptions(options)),
99
+ tag: 'organizations.list',
100
+ }),
101
+ ),
102
+ ),
103
+ tags: (data) => [
104
+ {type: 'organization', id: 'LIST'},
105
+ ...data.map((org) => ({type: 'organization', id: org.id})),
106
+ ],
107
+ })
@@ -2,92 +2,36 @@ import {expectTypeOf, test} from 'vitest'
2
2
 
3
3
  import {type SanityInstance} from '../store/createSanityInstance'
4
4
  import {type StateSource} from '../store/createStateSourceAction'
5
- import {
6
- getProjectState,
7
- type Project,
8
- type ProjectBase,
9
- type ProjectMember,
10
- resolveProject,
11
- } from './project'
5
+ import {type FetcherSnapshot} from '../store/fetcherStore'
6
+ import {type Project, project, type ProjectMember} from './project'
12
7
 
13
8
  const instance = {} as SanityInstance
14
9
 
15
- test('resolveProject — default call: members and features both included by default', () => {
16
- expectTypeOf(resolveProject(instance)).resolves.toEqualTypeOf<Project<true, true>>()
17
- type Result = Awaited<ReturnType<typeof resolveProject<true, true>>>
18
- expectTypeOf<Result['members']>().toEqualTypeOf<ProjectMember[]>()
19
- })
20
-
21
- test('resolveProject — includeMembers: false drops members from the type', () => {
22
- expectTypeOf(resolveProject(instance, {includeMembers: false})).resolves.toEqualTypeOf<
23
- Project<false, true>
24
- >()
25
- })
26
-
27
- test('resolveProject — includeFeatures: false drops features from the type', () => {
28
- expectTypeOf(resolveProject(instance, {includeFeatures: false})).resolves.toEqualTypeOf<
29
- Project<true, false>
30
- >()
31
- })
32
-
33
- test('resolveProject — both flags false → bare base shape', () => {
34
- expectTypeOf(
35
- resolveProject(instance, {includeMembers: false, includeFeatures: false}),
36
- ).resolves.toEqualTypeOf<Project<false, false>>()
37
- type Result = Awaited<ReturnType<typeof resolveProject<false, false>>>
38
- expectTypeOf<Result['id']>().toEqualTypeOf<string>()
39
- })
40
-
41
- test('resolveProject — rejects non-boolean flag values', () => {
42
- // @ts-expect-error — includeMembers must be a boolean
43
- void resolveProject(instance, {includeMembers: 'yes'})
44
- })
45
-
46
- test('resolveProject — projectId alone does not change the data shape', () => {
47
- expectTypeOf(resolveProject(instance, {projectId: 'p'})).resolves.toEqualTypeOf<
48
- Project<true, true>
49
- >()
50
- })
51
-
52
- test('resolveProject — non-literal boolean flag makes members optional', () => {
53
- const includeMembers = false as boolean
54
- expectTypeOf(resolveProject(instance, {includeMembers})).resolves.toEqualTypeOf<
55
- Project<boolean, true>
10
+ test('project.resolveState — resolves the wide-boolean detail shape', () => {
11
+ expectTypeOf(project.resolveState(instance, {projectId: 'p'})).resolves.toEqualTypeOf<
12
+ Project<boolean, boolean>
56
13
  >()
57
14
  })
58
15
 
59
- test('getProjectState — default call returns members + features StateSource', () => {
60
- expectTypeOf(getProjectState(instance)).toEqualTypeOf<
61
- StateSource<Project<true, true> | undefined>
62
- >()
16
+ test('Project — literal flags narrow members and features', () => {
17
+ expectTypeOf<Project<true, true>['members']>().toEqualTypeOf<ProjectMember[]>()
18
+ expectTypeOf<Project<true, true>['features']>().toEqualTypeOf<string[]>()
19
+ expectTypeOf<keyof Project<false, false> & ('members' | 'features')>().toEqualTypeOf<never>()
63
20
  })
64
21
 
65
- test('getProjectState — both flags false narrows to the bare base shape', () => {
66
- expectTypeOf(
67
- getProjectState(instance, {includeMembers: false, includeFeatures: false}),
68
- ).toEqualTypeOf<StateSource<Project<false, false> | undefined>>()
22
+ test('Project<boolean, boolean> — members and features are optional', () => {
23
+ type Item = Project<boolean, boolean>
24
+ expectTypeOf<Item['members']>().toEqualTypeOf<ProjectMember[] | undefined>()
25
+ expectTypeOf<Item['features']>().toEqualTypeOf<string[] | undefined>()
69
26
  })
70
27
 
71
- test('Project — wide boolean for IncludeMembers makes members optional', () => {
72
- expectTypeOf<Project<boolean, true>>().toEqualTypeOf<
73
- ProjectBase & {members?: ProjectMember[]} & {features: string[]}
28
+ test('project.getState — exposes the snapshot envelope', () => {
29
+ expectTypeOf(project.getState(instance, {projectId: 'p'})).toEqualTypeOf<
30
+ StateSource<FetcherSnapshot<Project<boolean, boolean>>>
74
31
  >()
75
- expectTypeOf<Pick<Project<boolean, true>, 'members'>>().toEqualTypeOf<{
76
- members?: ProjectMember[]
77
- }>()
78
32
  })
79
33
 
80
- test('Project — wide boolean for IncludeFeatures makes features optional', () => {
81
- expectTypeOf<Project<true, boolean>>().toEqualTypeOf<
82
- ProjectBase & {members: ProjectMember[]} & {features?: string[]}
83
- >()
84
- expectTypeOf<Pick<Project<true, boolean>, 'features'>>().toEqualTypeOf<{
85
- features?: string[]
86
- }>()
87
- })
88
-
89
- test('Project — both wide booleans make both fields optional', () => {
90
- expectTypeOf<Project<boolean, boolean>>().toEqualTypeOf<
91
- ProjectBase & {members?: ProjectMember[]} & {features?: string[]}
92
- >()
34
+ test('project — rejects non-boolean flag values', () => {
35
+ // @ts-expect-error includeMembers must be a boolean
36
+ void project.resolveState(instance, {includeMembers: 'yes'})
93
37
  })
@@ -3,9 +3,10 @@ import {of} from 'rxjs'
3
3
  import {afterEach, beforeEach, describe, it} from 'vitest'
4
4
 
5
5
  import {getClientState} from '../client/clientStore'
6
+ import {projects} from '../projects/projects'
6
7
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
8
  import {type StateSource} from '../store/createStateSourceAction'
8
- import {getProjectCacheKey, resolveProject} from './project'
9
+ import {getProjectCacheKey, project} from './project'
9
10
 
10
11
  vi.mock('../client/clientStore')
11
12
 
@@ -21,8 +22,8 @@ describe('project', () => {
21
22
  })
22
23
 
23
24
  it('calls `client.observable.request` against `/projects/<id>` and returns the result', async () => {
24
- const project = {id: 'a'}
25
- const request = vi.fn().mockReturnValue(of(project))
25
+ const detail = {id: 'a'}
26
+ const request = vi.fn().mockReturnValue(of(detail))
26
27
 
27
28
  const mockClient = {
28
29
  observable: {request} as unknown as SanityClient['observable'],
@@ -32,12 +33,12 @@ describe('project', () => {
32
33
  observable: of(mockClient),
33
34
  } as StateSource<SanityClient>)
34
35
 
35
- const result = await resolveProject(instance, {projectId: 'a'})
36
- expect(result).toEqual(project)
36
+ const result = await project.resolveState(instance, {projectId: 'a'})
37
+ expect(result).toEqual(detail)
37
38
  expect(request).toHaveBeenCalledWith({
38
39
  uri: '/projects/a',
39
40
  query: {includeMembers: 'true', includeFeatures: 'true'},
40
- tag: 'project.get',
41
+ tag: 'projects.get',
41
42
  })
42
43
  })
43
44
 
@@ -51,7 +52,7 @@ describe('project', () => {
51
52
  observable: of(mockClient),
52
53
  } as StateSource<SanityClient>)
53
54
 
54
- await resolveProject(instance, {
55
+ await project.resolveState(instance, {
55
56
  projectId: 'a',
56
57
  includeMembers: false,
57
58
  includeFeatures: false,
@@ -63,7 +64,7 @@ describe('project', () => {
63
64
  includeMembers: 'false',
64
65
  includeFeatures: 'false',
65
66
  },
66
- tag: 'project.get',
67
+ tag: 'projects.get',
67
68
  })
68
69
  })
69
70
 
@@ -77,13 +78,59 @@ describe('project', () => {
77
78
  observable: of(mockClient),
78
79
  } as StateSource<SanityClient>)
79
80
 
80
- await resolveProject(instance)
81
+ await project.resolveState(instance)
81
82
  expect(request).toHaveBeenCalledWith({
82
83
  uri: '/projects/p',
83
84
  query: {includeMembers: 'true', includeFeatures: 'true'},
84
- tag: 'project.get',
85
+ tag: 'projects.get',
85
86
  })
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'],
98
+ } as SanityClient
99
+
100
+ vi.mocked(getClientState).mockReturnValue({
101
+ observable: of(mockClient),
102
+ } as StateSource<SanityClient>)
103
+
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
+ })
87
134
  })
88
135
 
89
136
  describe('project cache key generation', () => {
@@ -2,9 +2,10 @@ import {switchMap} from 'rxjs'
2
2
 
3
3
  import {getClientState} from '../client/clientStore'
4
4
  import {type ProjectHandle} from '../config/sanityConfig'
5
+ import {projects} from '../projects/projects'
5
6
  import {type SanityInstance} from '../store/createSanityInstance'
6
- import {type StateSource} from '../store/createStateSourceAction'
7
- import {createFetcherStore} from '../utils/createFetcherStore'
7
+ import {defineFetcher} from '../store/fetcherStore'
8
+ import {buildQuery} from '../utils/buildQuery'
8
9
 
9
10
  const API_VERSION = 'v2025-02-19'
10
11
 
@@ -111,57 +112,37 @@ export function getProjectCacheKey(
111
112
  return `project:${projectId}${membersKey}${featuresKey}`
112
113
  }
113
114
 
114
- const project = createFetcherStore({
115
- name: 'Project',
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',
116
128
  getKey: getProjectCacheKey,
117
- fetcher: (instance) => (options?: ProjectOptions<boolean, boolean>) => {
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
118
144
  const projectId = resolveProjectId(instance, options)
119
-
120
- return getClientState(instance, {
121
- apiVersion: API_VERSION,
122
- scope: 'global',
123
- }).observable.pipe(
124
- switchMap((client) => {
125
- const normalized = normalizeProjectOptions(options)
126
- const query = Object.fromEntries(
127
- Object.entries(normalized)
128
- .filter(([, value]) => value !== undefined)
129
- .map(([key, value]) => [key, String(value)]),
130
- )
131
-
132
- return client.observable.request({
133
- uri: `/projects/${projectId}`,
134
- query,
135
- tag: 'project.get',
136
- })
137
- }),
138
- )
145
+ const match = snapshot.data.find((item) => item.id === projectId)
146
+ return match && {data: match, dataUpdatedAt: snapshot.dataUpdatedAt}
139
147
  },
140
148
  })
141
-
142
- /**
143
- * Public signature for the project state source. The conditional generics
144
- * cannot flow through `BoundStoreAction`, so we declare the signature here
145
- * and assign the (already-correct) runtime function to it.
146
- */
147
- type GetProjectState = <
148
- IncludeMembers extends boolean = true,
149
- IncludeFeatures extends boolean = true,
150
- >(
151
- instance: SanityInstance,
152
- options?: ProjectOptions<IncludeMembers, IncludeFeatures>,
153
- ) => StateSource<Project<IncludeMembers, IncludeFeatures> | undefined>
154
-
155
- type ResolveProject = <
156
- IncludeMembers extends boolean = true,
157
- IncludeFeatures extends boolean = true,
158
- >(
159
- instance: SanityInstance,
160
- options?: ProjectOptions<IncludeMembers, IncludeFeatures>,
161
- ) => Promise<Project<IncludeMembers, IncludeFeatures>>
162
-
163
- /** @public */
164
- export const getProjectState: GetProjectState = project.getState
165
-
166
- /** @public */
167
- export const resolveProject: ResolveProject = project.resolveState
@@ -3,36 +3,30 @@ import {expectTypeOf, test} from 'vitest'
3
3
  import {type Project, type ProjectMember} from '../project/project'
4
4
  import {type SanityInstance} from '../store/createSanityInstance'
5
5
  import {type StateSource} from '../store/createStateSourceAction'
6
- import {getProjectsState, resolveProjects} from './projects'
6
+ import {type FetcherSnapshot} from '../store/fetcherStore'
7
+ import {projects} from './projects'
7
8
 
8
9
  const instance = {} as SanityInstance
9
10
 
10
- test('resolveProjects — default call: features included, members omitted', () => {
11
- expectTypeOf(resolveProjects(instance)).resolves.toEqualTypeOf<Project<false, true>[]>()
12
- })
13
-
14
- test('resolveProjects — includeMembers: true adds members to the type', () => {
15
- expectTypeOf(resolveProjects(instance, {includeMembers: true})).resolves.toEqualTypeOf<
16
- Project<true, true>[]
11
+ test('projects.resolveState — resolves the wide-boolean list shape', () => {
12
+ expectTypeOf(projects.resolveState(instance)).resolves.toEqualTypeOf<
13
+ Project<boolean, boolean>[]
17
14
  >()
18
- type Result = Awaited<ReturnType<typeof resolveProjects<true, true>>>
19
- expectTypeOf<Result[number]['members']>().toEqualTypeOf<ProjectMember[]>()
20
15
  })
21
16
 
22
- test('resolveProjects — includeFeatures: false drops features from the type', () => {
23
- expectTypeOf(resolveProjects(instance, {includeFeatures: false})).resolves.toEqualTypeOf<
24
- Project<false, false>[]
25
- >()
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>()
26
21
  })
27
22
 
28
- test('resolveProjects — organizationId alone does not change the data shape', () => {
29
- expectTypeOf(resolveProjects(instance, {organizationId: 'org_123'})).resolves.toEqualTypeOf<
30
- Project<false, true>[]
23
+ test('projects.getState — exposes the snapshot envelope', () => {
24
+ expectTypeOf(projects.getState(instance)).toEqualTypeOf<
25
+ StateSource<FetcherSnapshot<Project<boolean, boolean>[]>>
31
26
  >()
32
27
  })
33
28
 
34
- test('getProjectsState — default call returns features-only StateSource', () => {
35
- expectTypeOf(getProjectsState(instance)).toEqualTypeOf<
36
- StateSource<Project<false, true>[] | undefined>
37
- >()
29
+ test('projects — rejects non-boolean flag values', () => {
30
+ // @ts-expect-error includeMembers must be a boolean
31
+ void projects.resolveState(instance, {includeMembers: 'yes'})
38
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 {getProjectsCacheKey, resolveProjects} from './projects'
8
+ import {getProjectsCacheKey, projects} from './projects'
9
9
 
10
10
  vi.mock('../client/clientStore')
11
11
 
@@ -21,8 +21,8 @@ describe('projects', () => {
21
21
  })
22
22
 
23
23
  it('calls `client.observable.request` against `/projects` and returns the result', async () => {
24
- const projects = [{id: 'a'}, {id: 'b'}]
25
- const request = vi.fn().mockReturnValue(of(projects))
24
+ const list = [{id: 'a'}, {id: 'b'}]
25
+ const request = vi.fn().mockReturnValue(of(list))
26
26
 
27
27
  const mockClient = {
28
28
  observable: {request} as unknown as SanityClient['observable'],
@@ -32,12 +32,12 @@ describe('projects', () => {
32
32
  observable: of(mockClient),
33
33
  } as StateSource<SanityClient>)
34
34
 
35
- const result = await resolveProjects(instance)
36
- expect(result).toEqual(projects)
35
+ const result = await projects.resolveState(instance)
36
+ expect(result).toEqual(list)
37
37
  expect(request).toHaveBeenCalledWith({
38
38
  uri: '/projects',
39
39
  query: {includeMembers: 'false', includeFeatures: 'true', onlyExplicitMembership: 'false'},
40
- tag: 'projects.get',
40
+ tag: 'projects.list',
41
41
  })
42
42
  })
43
43
 
@@ -51,7 +51,7 @@ describe('projects', () => {
51
51
  observable: of(mockClient),
52
52
  } as StateSource<SanityClient>)
53
53
 
54
- await resolveProjects(instance, {
54
+ await projects.resolveState(instance, {
55
55
  organizationId: 'org123',
56
56
  includeMembers: true,
57
57
  includeFeatures: false,
@@ -65,7 +65,7 @@ describe('projects', () => {
65
65
  includeFeatures: 'false',
66
66
  onlyExplicitMembership: 'false',
67
67
  },
68
- tag: 'projects.get',
68
+ tag: 'projects.list',
69
69
  })
70
70
  })
71
71
  })
@@ -3,8 +3,8 @@ import {switchMap} from 'rxjs'
3
3
  import {getClientState} from '../client/clientStore'
4
4
  import {type Project} from '../project/project'
5
5
  import {type SanityInstance} from '../store/createSanityInstance'
6
- import {type StateSource} from '../store/createStateSourceAction'
7
- import {createFetcherStore} from '../utils/createFetcherStore'
6
+ import {defineFetcher} from '../store/fetcherStore'
7
+ import {buildQuery} from '../utils/buildQuery'
8
8
 
9
9
  const API_VERSION = 'v2025-02-19'
10
10
 
@@ -42,54 +42,30 @@ export function getProjectsCacheKey(
42
42
  return `projects${orgKey}${membersKey}${featuresKey}${explicitKey}`
43
43
  }
44
44
 
45
- const projects = createFetcherStore({
46
- name: 'Projects',
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',
47
56
  getKey: getProjectsCacheKey,
48
- fetcher: (instance) => (options?: ProjectsOptions<boolean, boolean>) =>
49
- getClientState(instance, {
50
- apiVersion: API_VERSION,
51
- scope: 'global',
52
- }).observable.pipe(
53
- switchMap((client) => {
54
- const normalized = normalizeProjectsOptions(options)
55
- const query = Object.fromEntries(
56
- Object.entries(normalized)
57
- .filter(([, value]) => value !== undefined)
58
- .map(([key, value]) => [key, String(value)]),
59
- )
60
-
61
- return client.observable.request({
57
+ fetch: (instance) => (options) =>
58
+ getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
59
+ switchMap((client) =>
60
+ client.observable.request<Project<boolean, boolean>[]>({
62
61
  uri: '/projects',
63
- query,
64
- tag: 'projects.get',
65
- })
66
- }),
62
+ query: buildQuery(normalizeProjectsOptions(options)),
63
+ tag: 'projects.list',
64
+ }),
65
+ ),
67
66
  ),
67
+ tags: (data) => [
68
+ {type: 'project', id: 'LIST'},
69
+ ...data.map((project) => ({type: 'project', id: project.id})),
70
+ ],
68
71
  })
69
-
70
- /**
71
- * Public signature for the projects state source. The conditional generics
72
- * cannot flow through `BoundStoreAction`, so we declare the signature here
73
- * and assign the (already-correct) runtime function to it.
74
- */
75
- type GetProjectsState = <
76
- IncludeMembers extends boolean = false,
77
- IncludeFeatures extends boolean = true,
78
- >(
79
- instance: SanityInstance,
80
- options?: ProjectsOptions<IncludeMembers, IncludeFeatures>,
81
- ) => StateSource<Project<IncludeMembers, IncludeFeatures>[] | undefined>
82
-
83
- type ResolveProjects = <
84
- IncludeMembers extends boolean = false,
85
- IncludeFeatures extends boolean = true,
86
- >(
87
- instance: SanityInstance,
88
- options?: ProjectsOptions<IncludeMembers, IncludeFeatures>,
89
- ) => Promise<Project<IncludeMembers, IncludeFeatures>[]>
90
-
91
- /** @public */
92
- export const getProjectsState: GetProjectsState = projects.getState
93
-
94
- /** @public */
95
- export const resolveProjects: ResolveProjects = projects.resolveState