@sanity/sdk 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks-dts/utils.d.ts +200 -28
- package/dist/_chunks-es/_internal.js +3 -14
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +7 -14
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/_exports/_internal.d.ts +16 -2
- package/dist/_exports/_internal.js +3 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +168 -88
- package/dist/index.js.map +1 -1
- package/package.json +7 -9
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/index.ts +25 -2
- package/src/agent/agentActions.ts +21 -25
- package/src/client/clientStore.test.ts +10 -46
- package/src/client/clientStore.ts +7 -14
- package/src/comlink/node/actions/getOrCreateNode.test.ts +5 -2
- package/src/comlink/node/actions/releaseNode.test.ts +3 -3
- package/src/config/sanityConfig.ts +0 -1
- package/src/document/documentStore.ts +2 -7
- package/src/document/sharedListener.ts +3 -5
- package/src/organization/organization.test-d.ts +102 -0
- package/src/organization/organization.test.ts +138 -0
- package/src/organization/organization.ts +166 -0
- package/src/organizations/organizations.test-d.ts +77 -0
- package/src/organizations/organizations.test.ts +150 -0
- package/src/organizations/organizations.ts +132 -0
- package/src/presence/presenceStore.test.ts +5 -5
- package/src/preview/previewProjectionUtils.ts +2 -3
- package/src/project/project.test-d.ts +93 -0
- package/src/project/project.test.ts +108 -10
- package/src/project/project.ts +152 -26
- package/src/projection/subscribeToStateAndFetchBatches.ts +4 -9
- package/src/projects/projects.test-d.ts +38 -0
- package/src/projects/projects.test.ts +104 -38
- package/src/projects/projects.ts +74 -14
- package/src/query/queryStore.ts +2 -3
- package/src/releases/releasesStore.test.ts +1 -1
- package/src/releases/releasesStore.ts +2 -2
- package/src/store/createSanityInstance.ts +3 -3
- package/src/telemetry/devMode.test.ts +8 -0
- package/src/telemetry/devMode.ts +10 -9
- package/src/telemetry/initTelemetry.test.ts +0 -17
- package/src/telemetry/initTelemetry.ts +2 -12
|
@@ -1,25 +1,31 @@
|
|
|
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 {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
7
7
|
import {type StateSource} from '../store/createStateSourceAction'
|
|
8
|
-
import {resolveProject} from './project'
|
|
8
|
+
import {getProjectCacheKey, resolveProject} from './project'
|
|
9
9
|
|
|
10
10
|
vi.mock('../client/clientStore')
|
|
11
11
|
|
|
12
12
|
describe('project', () => {
|
|
13
|
-
|
|
14
|
-
const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
13
|
+
let instance: SanityInstance
|
|
15
14
|
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
instance.dispose()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('calls `client.observable.request` against `/projects/<id>` and returns the result', async () => {
|
|
16
24
|
const project = {id: 'a'}
|
|
17
|
-
const
|
|
25
|
+
const request = vi.fn().mockReturnValue(of(project))
|
|
18
26
|
|
|
19
27
|
const mockClient = {
|
|
20
|
-
observable: {
|
|
21
|
-
projects: {getById} as unknown as SanityClient['observable']['projects'],
|
|
22
|
-
},
|
|
28
|
+
observable: {request} as unknown as SanityClient['observable'],
|
|
23
29
|
} as SanityClient
|
|
24
30
|
|
|
25
31
|
vi.mocked(getClientState).mockReturnValue({
|
|
@@ -28,6 +34,98 @@ describe('project', () => {
|
|
|
28
34
|
|
|
29
35
|
const result = await resolveProject(instance, {projectId: 'a'})
|
|
30
36
|
expect(result).toEqual(project)
|
|
31
|
-
expect(
|
|
37
|
+
expect(request).toHaveBeenCalledWith({
|
|
38
|
+
uri: '/projects/a',
|
|
39
|
+
query: {includeMembers: 'true', includeFeatures: 'true'},
|
|
40
|
+
tag: 'project.get',
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('serializes query params (booleans → strings) and respects flags', async () => {
|
|
45
|
+
const request = vi.fn().mockReturnValue(of({id: 'a'}))
|
|
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 resolveProject(instance, {
|
|
55
|
+
projectId: 'a',
|
|
56
|
+
includeMembers: false,
|
|
57
|
+
includeFeatures: false,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
expect(request).toHaveBeenCalledWith({
|
|
61
|
+
uri: '/projects/a',
|
|
62
|
+
query: {
|
|
63
|
+
includeMembers: 'false',
|
|
64
|
+
includeFeatures: 'false',
|
|
65
|
+
},
|
|
66
|
+
tag: 'project.get',
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('falls back to the instance projectId when none is provided in options', async () => {
|
|
71
|
+
const request = vi.fn().mockReturnValue(of({id: 'p'}))
|
|
72
|
+
const mockClient = {
|
|
73
|
+
observable: {request} as unknown as SanityClient['observable'],
|
|
74
|
+
} as SanityClient
|
|
75
|
+
|
|
76
|
+
vi.mocked(getClientState).mockReturnValue({
|
|
77
|
+
observable: of(mockClient),
|
|
78
|
+
} as StateSource<SanityClient>)
|
|
79
|
+
|
|
80
|
+
await resolveProject(instance)
|
|
81
|
+
expect(request).toHaveBeenCalledWith({
|
|
82
|
+
uri: '/projects/p',
|
|
83
|
+
query: {includeMembers: 'true', includeFeatures: 'true'},
|
|
84
|
+
tag: 'project.get',
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('project cache key generation', () => {
|
|
90
|
+
const mockInstance = {config: {projectId: 'p'}} as SanityInstance
|
|
91
|
+
|
|
92
|
+
it('default call includes :members and :features (both default-true)', () => {
|
|
93
|
+
expect(getProjectCacheKey(mockInstance)).toBe('project:p:members:features')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('treats undefined and the matching default as the same key', () => {
|
|
97
|
+
expect(getProjectCacheKey(mockInstance)).toBe(
|
|
98
|
+
getProjectCacheKey(mockInstance, {includeMembers: true, includeFeatures: true}),
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('explicit includeFeatures: false drops the :features segment', () => {
|
|
103
|
+
expect(getProjectCacheKey(mockInstance, {includeFeatures: false})).toBe('project:p:members')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('explicit includeMembers: false drops the :members segment', () => {
|
|
107
|
+
expect(getProjectCacheKey(mockInstance, {includeMembers: false})).toBe('project:p:features')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('combines all segments in order', () => {
|
|
111
|
+
expect(
|
|
112
|
+
getProjectCacheKey(mockInstance, {
|
|
113
|
+
projectId: 'a',
|
|
114
|
+
includeMembers: true,
|
|
115
|
+
includeFeatures: true,
|
|
116
|
+
}),
|
|
117
|
+
).toBe('project:a:members:features')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('produces distinct keys for each meaningful option permutation', () => {
|
|
121
|
+
const keys = new Set([
|
|
122
|
+
getProjectCacheKey(mockInstance),
|
|
123
|
+
getProjectCacheKey(mockInstance, {includeMembers: false}),
|
|
124
|
+
getProjectCacheKey(mockInstance, {includeFeatures: false}),
|
|
125
|
+
getProjectCacheKey(mockInstance, {includeMembers: false, includeFeatures: false}),
|
|
126
|
+
getProjectCacheKey(mockInstance, {projectId: 'a'}),
|
|
127
|
+
getProjectCacheKey(mockInstance, {projectId: 'b'}),
|
|
128
|
+
])
|
|
129
|
+
expect(keys.size).toBe(6)
|
|
32
130
|
})
|
|
33
131
|
})
|
package/src/project/project.ts
CHANGED
|
@@ -2,40 +2,166 @@ import {switchMap} from 'rxjs'
|
|
|
2
2
|
|
|
3
3
|
import {getClientState} from '../client/clientStore'
|
|
4
4
|
import {type ProjectHandle} from '../config/sanityConfig'
|
|
5
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
6
|
+
import {type StateSource} from '../store/createStateSourceAction'
|
|
5
7
|
import {createFetcherStore} from '../utils/createFetcherStore'
|
|
6
8
|
|
|
7
9
|
const API_VERSION = 'v2025-02-19'
|
|
8
10
|
|
|
11
|
+
/** @public */
|
|
12
|
+
export interface ProjectMemberRole {
|
|
13
|
+
name: string
|
|
14
|
+
title: string
|
|
15
|
+
description: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @public */
|
|
19
|
+
export interface ProjectMember {
|
|
20
|
+
id: string
|
|
21
|
+
createdAt: string
|
|
22
|
+
updatedAt: string
|
|
23
|
+
isCurrentUser: boolean
|
|
24
|
+
isRobot: boolean
|
|
25
|
+
roles: ProjectMemberRole[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @public */
|
|
29
|
+
export interface ProjectMetadata {
|
|
30
|
+
color?: string
|
|
31
|
+
externalStudioHost?: string
|
|
32
|
+
initialTemplate?: string
|
|
33
|
+
cliInitializedAt?: string
|
|
34
|
+
integration: 'manage' | 'cli'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The base fields returned from `/projects` for every project.
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export interface ProjectBase {
|
|
42
|
+
id: string
|
|
43
|
+
displayName: string
|
|
44
|
+
studioHost: string | null
|
|
45
|
+
organizationId: string
|
|
46
|
+
metadata: ProjectMetadata
|
|
47
|
+
isBlocked: boolean
|
|
48
|
+
isDisabled: boolean
|
|
49
|
+
isDisabledByUser: boolean
|
|
50
|
+
activityFeedEnabled: boolean
|
|
51
|
+
createdAt: string
|
|
52
|
+
updatedAt: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A `Project` with `members` and/or `features` conditionally included
|
|
57
|
+
* based on the query options used to fetch it.
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
export type Project<
|
|
61
|
+
IncludeMembers extends boolean = true,
|
|
62
|
+
IncludeFeatures extends boolean = true,
|
|
63
|
+
> = ProjectBase &
|
|
64
|
+
// `boolean extends T` is non-distributive — true only when T is the wide
|
|
65
|
+
// `boolean`, in which case the field is optional. Literal `true`/`false`
|
|
66
|
+
// fall through to the strict branch.
|
|
67
|
+
(boolean extends IncludeMembers
|
|
68
|
+
? {members?: ProjectMember[]}
|
|
69
|
+
: IncludeMembers extends true
|
|
70
|
+
? {members: ProjectMember[]}
|
|
71
|
+
: unknown) &
|
|
72
|
+
(boolean extends IncludeFeatures
|
|
73
|
+
? {features?: string[]}
|
|
74
|
+
: IncludeFeatures extends true
|
|
75
|
+
? {features: string[]}
|
|
76
|
+
: unknown)
|
|
77
|
+
|
|
78
|
+
/** @public */
|
|
79
|
+
export interface ProjectOptions<
|
|
80
|
+
IncludeMembers extends boolean = true,
|
|
81
|
+
IncludeFeatures extends boolean = true,
|
|
82
|
+
> extends ProjectHandle {
|
|
83
|
+
includeMembers?: IncludeMembers
|
|
84
|
+
includeFeatures?: IncludeFeatures
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeProjectOptions(options?: ProjectOptions<boolean, boolean>) {
|
|
88
|
+
return {
|
|
89
|
+
includeMembers: options?.includeMembers ?? true,
|
|
90
|
+
includeFeatures: options?.includeFeatures ?? true,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveProjectId(instance: SanityInstance, options?: ProjectOptions<boolean, boolean>) {
|
|
95
|
+
const projectId = options?.projectId ?? instance.config.projectId
|
|
96
|
+
if (!projectId) {
|
|
97
|
+
throw new Error('A projectId is required to use the project API.')
|
|
98
|
+
}
|
|
99
|
+
return projectId
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @internal */
|
|
103
|
+
export function getProjectCacheKey(
|
|
104
|
+
instance: SanityInstance,
|
|
105
|
+
options?: ProjectOptions<boolean, boolean>,
|
|
106
|
+
): string {
|
|
107
|
+
const projectId = resolveProjectId(instance, options)
|
|
108
|
+
const {includeMembers, includeFeatures} = normalizeProjectOptions(options)
|
|
109
|
+
const membersKey = includeMembers ? ':members' : ''
|
|
110
|
+
const featuresKey = includeFeatures ? ':features' : ''
|
|
111
|
+
return `project:${projectId}${membersKey}${featuresKey}`
|
|
112
|
+
}
|
|
113
|
+
|
|
9
114
|
const project = createFetcherStore({
|
|
10
115
|
name: 'Project',
|
|
11
|
-
getKey:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
116
|
+
getKey: getProjectCacheKey,
|
|
117
|
+
fetcher: (instance) => (options?: ProjectOptions<boolean, boolean>) => {
|
|
118
|
+
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
|
+
)
|
|
17
139
|
},
|
|
18
|
-
fetcher:
|
|
19
|
-
(instance) =>
|
|
20
|
-
(options: ProjectHandle = {}) => {
|
|
21
|
-
const projectId = options.projectId ?? instance.config.projectId
|
|
22
|
-
|
|
23
|
-
return getClientState(instance, {
|
|
24
|
-
apiVersion: API_VERSION,
|
|
25
|
-
scope: 'global',
|
|
26
|
-
projectId,
|
|
27
|
-
}).observable.pipe(
|
|
28
|
-
switchMap((client) =>
|
|
29
|
-
client.observable.projects.getById(
|
|
30
|
-
// non-null assertion is fine with the above throwing
|
|
31
|
-
(projectId ?? instance.config.projectId)!,
|
|
32
|
-
),
|
|
33
|
-
),
|
|
34
|
-
)
|
|
35
|
-
},
|
|
36
140
|
})
|
|
37
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
|
+
|
|
38
163
|
/** @public */
|
|
39
|
-
export const getProjectState = project.getState
|
|
164
|
+
export const getProjectState: GetProjectState = project.getState
|
|
165
|
+
|
|
40
166
|
/** @public */
|
|
41
|
-
export const resolveProject = project.resolveState
|
|
167
|
+
export const resolveProject: ResolveProject = project.resolveState
|
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
tap,
|
|
17
17
|
} from 'rxjs'
|
|
18
18
|
|
|
19
|
-
import {isDatasetResource} from '../config/sanityConfig'
|
|
20
19
|
import {getQueryState, resolveQuery} from '../query/queryStore'
|
|
21
20
|
import {type BoundPerspectiveKey} from '../store/createActionBinder'
|
|
22
21
|
import {type StoreContext} from '../store/defineStore'
|
|
@@ -113,8 +112,7 @@ export const subscribeToStateAndFetchBatches = ({
|
|
|
113
112
|
tag: PROJECTION_TAG,
|
|
114
113
|
perspective,
|
|
115
114
|
},
|
|
116
|
-
|
|
117
|
-
...(resource && !isDatasetResource(resource) ? {resource} : {}),
|
|
115
|
+
resource,
|
|
118
116
|
})
|
|
119
117
|
|
|
120
118
|
const querySource$ = defer(() => {
|
|
@@ -128,8 +126,7 @@ export const subscribeToStateAndFetchBatches = ({
|
|
|
128
126
|
signal: controller.signal,
|
|
129
127
|
perspective,
|
|
130
128
|
},
|
|
131
|
-
|
|
132
|
-
...(resource && !isDatasetResource(resource) ? {resource} : {}),
|
|
129
|
+
resource,
|
|
133
130
|
}),
|
|
134
131
|
).pipe(switchMap(() => observable))
|
|
135
132
|
}
|
|
@@ -154,8 +151,7 @@ export const subscribeToStateAndFetchBatches = ({
|
|
|
154
151
|
tag: PROJECTION_TAG,
|
|
155
152
|
perspective: 'raw',
|
|
156
153
|
},
|
|
157
|
-
|
|
158
|
-
...(resource && !isDatasetResource(resource) ? {resource} : {}),
|
|
154
|
+
resource,
|
|
159
155
|
})
|
|
160
156
|
|
|
161
157
|
const statusQuerySource$ = defer(() => {
|
|
@@ -169,8 +165,7 @@ export const subscribeToStateAndFetchBatches = ({
|
|
|
169
165
|
signal: controller.signal,
|
|
170
166
|
perspective: 'raw',
|
|
171
167
|
},
|
|
172
|
-
|
|
173
|
-
...(resource && !isDatasetResource(resource) ? {resource} : {}),
|
|
168
|
+
resource,
|
|
174
169
|
}),
|
|
175
170
|
).pipe(switchMap(() => observable))
|
|
176
171
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 {getProjectsState, resolveProjects} from './projects'
|
|
7
|
+
|
|
8
|
+
const instance = {} as SanityInstance
|
|
9
|
+
|
|
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>[]
|
|
17
|
+
>()
|
|
18
|
+
type Result = Awaited<ReturnType<typeof resolveProjects<true, true>>>
|
|
19
|
+
expectTypeOf<Result[number]['members']>().toEqualTypeOf<ProjectMember[]>()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('resolveProjects — includeFeatures: false drops features from the type', () => {
|
|
23
|
+
expectTypeOf(resolveProjects(instance, {includeFeatures: false})).resolves.toEqualTypeOf<
|
|
24
|
+
Project<false, false>[]
|
|
25
|
+
>()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('resolveProjects — organizationId alone does not change the data shape', () => {
|
|
29
|
+
expectTypeOf(resolveProjects(instance, {organizationId: 'org_123'})).resolves.toEqualTypeOf<
|
|
30
|
+
Project<false, true>[]
|
|
31
|
+
>()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('getProjectsState — default call returns features-only StateSource', () => {
|
|
35
|
+
expectTypeOf(getProjectsState(instance)).toEqualTypeOf<
|
|
36
|
+
StateSource<Project<false, true>[] | undefined>
|
|
37
|
+
>()
|
|
38
|
+
})
|
|
@@ -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, resolveProjects} from './projects'
|
|
9
9
|
|
|
10
10
|
vi.mock('../client/clientStore')
|
|
11
11
|
|
|
@@ -20,14 +20,12 @@ describe('projects', () => {
|
|
|
20
20
|
instance.dispose()
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
it('calls
|
|
23
|
+
it('calls `client.observable.request` against `/projects` and returns the result', async () => {
|
|
24
24
|
const projects = [{id: 'a'}, {id: 'b'}]
|
|
25
|
-
const
|
|
25
|
+
const request = vi.fn().mockReturnValue(of(projects))
|
|
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({
|
|
@@ -36,41 +34,109 @@ describe('projects', () => {
|
|
|
36
34
|
|
|
37
35
|
const result = await resolveProjects(instance)
|
|
38
36
|
expect(result).toEqual(projects)
|
|
39
|
-
expect(
|
|
37
|
+
expect(request).toHaveBeenCalledWith({
|
|
38
|
+
uri: '/projects',
|
|
39
|
+
query: {includeMembers: 'false', includeFeatures: 'true', onlyExplicitMembership: 'false'},
|
|
40
|
+
tag: 'projects.get',
|
|
41
|
+
})
|
|
40
42
|
})
|
|
41
|
-
})
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 resolveProjects(instance, {
|
|
71
55
|
organizationId: 'org123',
|
|
72
|
-
includeMembers:
|
|
56
|
+
includeMembers: true,
|
|
57
|
+
includeFeatures: false,
|
|
73
58
|
})
|
|
74
|
-
|
|
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.get',
|
|
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
|
})
|