@sanity/sdk 2.9.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.
Files changed (73) hide show
  1. package/dist/_chunks-dts/utils.d.ts +295 -69
  2. package/dist/_chunks-es/_internal.js +3 -14
  3. package/dist/_chunks-es/_internal.js.map +1 -1
  4. package/dist/_chunks-es/createGroqSearchFilter.js +129 -59
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  6. package/dist/_chunks-es/version.js +1 -1
  7. package/dist/_exports/_internal.d.ts +16 -2
  8. package/dist/_exports/_internal.js +3 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +275 -149
  11. package/dist/index.js.map +1 -1
  12. package/package.json +11 -15
  13. package/src/_exports/_internal.ts +1 -0
  14. package/src/_exports/index.ts +33 -2
  15. package/src/agent/agentActions.ts +21 -25
  16. package/src/client/clientStore.test.ts +24 -60
  17. package/src/client/clientStore.ts +49 -56
  18. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  19. package/src/comlink/node/actions/getOrCreateNode.test.ts +5 -2
  20. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  21. package/src/comlink/node/actions/releaseNode.test.ts +3 -3
  22. package/src/config/sanityConfig.ts +72 -13
  23. package/src/document/applyDocumentActions.test.ts +7 -7
  24. package/src/document/applyDocumentActions.ts +5 -5
  25. package/src/document/documentStore.test.ts +68 -62
  26. package/src/document/documentStore.ts +33 -38
  27. package/src/document/processActions.ts +2 -2
  28. package/src/document/reducers.ts +4 -4
  29. package/src/document/sharedListener.ts +5 -7
  30. package/src/organization/organization.test-d.ts +102 -0
  31. package/src/organization/organization.test.ts +138 -0
  32. package/src/organization/organization.ts +166 -0
  33. package/src/organizations/organizations.test-d.ts +77 -0
  34. package/src/organizations/organizations.test.ts +150 -0
  35. package/src/organizations/organizations.ts +132 -0
  36. package/src/presence/bifurTransport.test.ts +46 -6
  37. package/src/presence/bifurTransport.ts +13 -1
  38. package/src/presence/presenceStore.test.ts +101 -5
  39. package/src/presence/presenceStore.ts +96 -24
  40. package/src/preview/getPreviewState.ts +1 -1
  41. package/src/preview/previewProjectionUtils.test.ts +4 -4
  42. package/src/preview/previewProjectionUtils.ts +6 -7
  43. package/src/preview/resolvePreview.ts +5 -1
  44. package/src/project/project.test-d.ts +93 -0
  45. package/src/project/project.test.ts +108 -10
  46. package/src/project/project.ts +152 -26
  47. package/src/projection/getProjectionState.ts +4 -4
  48. package/src/projection/projectionStore.test.ts +2 -2
  49. package/src/projection/resolveProjection.ts +2 -2
  50. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  51. package/src/projection/subscribeToStateAndFetchBatches.ts +11 -15
  52. package/src/projects/projects.test-d.ts +38 -0
  53. package/src/projects/projects.test.ts +104 -38
  54. package/src/projects/projects.ts +74 -14
  55. package/src/query/queryStore.test.ts +12 -12
  56. package/src/query/queryStore.ts +10 -11
  57. package/src/query/reducers.ts +3 -3
  58. package/src/releases/getPerspectiveState.ts +5 -5
  59. package/src/releases/releasesStore.test.ts +6 -6
  60. package/src/releases/releasesStore.ts +9 -9
  61. package/src/store/createActionBinder.test.ts +31 -31
  62. package/src/store/createActionBinder.ts +43 -38
  63. package/src/store/createSanityInstance.ts +5 -6
  64. package/src/telemetry/devMode.test.ts +8 -0
  65. package/src/telemetry/devMode.ts +10 -9
  66. package/src/telemetry/initTelemetry.test.ts +0 -17
  67. package/src/telemetry/initTelemetry.ts +2 -12
  68. package/src/users/reducers.ts +3 -4
  69. package/src/utils/createFetcherStore.ts +6 -4
  70. package/src/utils/isImportError.test.ts +72 -0
  71. package/src/utils/isImportError.ts +34 -0
  72. package/src/utils/object.test.ts +95 -0
  73. package/src/utils/object.ts +142 -0
@@ -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: (instance, options?: ProjectHandle) => {
12
- const projectId = options?.projectId ?? instance.config.projectId
13
- if (!projectId) {
14
- throw new Error('A projectId is required to use the project API.')
15
- }
16
- return projectId
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
@@ -1,9 +1,8 @@
1
1
  import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
2
  import {type SanityProjectionResult} from 'groq'
3
- import {omit} from 'lodash-es'
4
3
 
5
4
  import {type DocumentHandle} from '../config/sanityConfig'
6
- import {bindActionBySourceAndPerspective} from '../store/createActionBinder'
5
+ import {bindActionByResourceAndPerspective} from '../store/createActionBinder'
7
6
  import {type SanityInstance} from '../store/createSanityInstance'
8
7
  import {
9
8
  createStateSourceAction,
@@ -12,6 +11,7 @@ import {
12
11
  } from '../store/createStateSourceAction'
13
12
  import {hashString} from '../utils/hashString'
14
13
  import {insecureRandomId} from '../utils/ids'
14
+ import {omitProperty} from '../utils/object'
15
15
  import {setCleanupTimeout} from '../utils/setCleanupTimeout'
16
16
  import {projectionStore} from './projectionStore'
17
17
  import {type ProjectionStoreState, type ProjectionValuePending} from './types'
@@ -72,7 +72,7 @@ export function getProjectionState(
72
72
  /**
73
73
  * @beta
74
74
  */
75
- export const _getProjectionState = bindActionBySourceAndPerspective(
75
+ export const _getProjectionState = bindActionByResourceAndPerspective(
76
76
  projectionStore,
77
77
  createStateSourceAction({
78
78
  selector: (
@@ -113,7 +113,7 @@ export const _getProjectionState = bindActionBySourceAndPerspective(
113
113
  return () => {
114
114
  setCleanupTimeout(() => {
115
115
  state.set('removeSubscription', (prev): Partial<ProjectionStoreState> => {
116
- const documentSubscriptionsForHash = omit(
116
+ const documentSubscriptionsForHash = omitProperty(
117
117
  prev.subscriptions[documentId]?.[projectionHash],
118
118
  subscriptionId,
119
119
  )
@@ -30,7 +30,7 @@ describe('projectionStore', () => {
30
30
  instance,
31
31
  {
32
32
  name: 'p.d',
33
- source: {projectId: 'p', dataset: 'd'},
33
+ resource: {projectId: 'p', dataset: 'd'},
34
34
  perspective: 'drafts',
35
35
  },
36
36
  projectionStore,
@@ -42,7 +42,7 @@ describe('projectionStore', () => {
42
42
  state,
43
43
  key: {
44
44
  name: 'p.d',
45
- source: {projectId: 'p', dataset: 'd'},
45
+ resource: {projectId: 'p', dataset: 'd'},
46
46
  perspective: 'drafts',
47
47
  },
48
48
  })
@@ -1,7 +1,7 @@
1
1
  import {type SanityProjectionResult} from 'groq'
2
2
  import {filter, firstValueFrom} from 'rxjs'
3
3
 
4
- import {bindActionBySourceAndPerspective} from '../store/createActionBinder'
4
+ import {bindActionByResourceAndPerspective} from '../store/createActionBinder'
5
5
  import {type SanityInstance} from '../store/createSanityInstance'
6
6
  import {getProjectionState, type ProjectionOptions} from './getProjectionState'
7
7
  import {projectionStore} from './projectionStore'
@@ -38,7 +38,7 @@ export function resolveProjection(
38
38
  /**
39
39
  * @beta
40
40
  */
41
- const _resolveProjection = bindActionBySourceAndPerspective(
41
+ const _resolveProjection = bindActionByResourceAndPerspective(
42
42
  projectionStore,
43
43
  (
44
44
  {instance}: {instance: SanityInstance},
@@ -17,7 +17,7 @@ describe('subscribeToStateAndFetchBatches', () => {
17
17
  let state: StoreState<ProjectionStoreState>
18
18
  const key = {
19
19
  name: 'test.test:drafts',
20
- source: {projectId: 'test', dataset: 'test'},
20
+ resource: {projectId: 'test', dataset: 'test'},
21
21
  perspective: 'drafts' as const,
22
22
  }
23
23
 
@@ -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,
@@ -16,10 +16,10 @@ import {
16
16
  tap,
17
17
  } from 'rxjs'
18
18
 
19
- import {isDatasetSource} 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'
22
+ import {isDeepEqual} from '../utils/object'
23
23
  import {
24
24
  createProjectionQuery,
25
25
  processProjectionQuery,
@@ -42,22 +42,22 @@ interface StatusQueryResult {
42
42
  export const subscribeToStateAndFetchBatches = ({
43
43
  state,
44
44
  instance,
45
- key: {source, perspective},
45
+ key: {resource, perspective},
46
46
  }: StoreContext<ProjectionStoreState, BoundPerspectiveKey>): Subscription => {
47
47
  const documentProjections$ = state.observable.pipe(
48
48
  map((s) => s.documentProjections),
49
- distinctUntilChanged(isEqual),
49
+ distinctUntilChanged(isDeepEqual),
50
50
  )
51
51
 
52
52
  const activeDocumentIds$ = state.observable.pipe(
53
- map(({subscriptions}) => new Set(Object.keys(subscriptions))),
53
+ map(({subscriptions}) => new Set(Object.keys(subscriptions).map((id) => DocumentId(id)))),
54
54
  distinctUntilChanged(isSetEqual),
55
55
  )
56
56
 
57
57
  const pendingUpdateSubscription = activeDocumentIds$
58
58
  .pipe(
59
59
  debounceTime(BATCH_DEBOUNCE_TIME),
60
- startWith(new Set<string>()),
60
+ startWith(new Set<DocumentId>()),
61
61
  pairwise(),
62
62
  tap(([prevIds, currIds]) => {
63
63
  const newIds = [...currIds].filter((id) => !prevIds.has(id))
@@ -89,7 +89,7 @@ export const subscribeToStateAndFetchBatches = ({
89
89
 
90
90
  const queryTrigger$ = combineLatest([activeDocumentIds$, documentProjections$]).pipe(
91
91
  debounceTime(BATCH_DEBOUNCE_TIME),
92
- distinctUntilChanged(isEqual),
92
+ distinctUntilChanged(isDeepEqual),
93
93
  )
94
94
 
95
95
  const queryExecutionSubscription = queryTrigger$
@@ -112,8 +112,7 @@ export const subscribeToStateAndFetchBatches = ({
112
112
  tag: PROJECTION_TAG,
113
113
  perspective,
114
114
  },
115
- // temporary guard here until we're ready for everything to be queried via global API
116
- ...(source && !isDatasetSource(source) ? {source} : {}),
115
+ resource,
117
116
  })
118
117
 
119
118
  const querySource$ = defer(() => {
@@ -127,8 +126,7 @@ export const subscribeToStateAndFetchBatches = ({
127
126
  signal: controller.signal,
128
127
  perspective,
129
128
  },
130
- // temporary guard here until we're ready for everything to be queried via global API in v3
131
- ...(source && !isDatasetSource(source) ? {source} : {}),
129
+ resource,
132
130
  }),
133
131
  ).pipe(switchMap(() => observable))
134
132
  }
@@ -153,8 +151,7 @@ export const subscribeToStateAndFetchBatches = ({
153
151
  tag: PROJECTION_TAG,
154
152
  perspective: 'raw',
155
153
  },
156
- // temporary guard here until we're ready for everything to be queried via global API
157
- ...(source && !isDatasetSource(source) ? {source} : {}),
154
+ resource,
158
155
  })
159
156
 
160
157
  const statusQuerySource$ = defer(() => {
@@ -168,8 +165,7 @@ export const subscribeToStateAndFetchBatches = ({
168
165
  signal: controller.signal,
169
166
  perspective: 'raw',
170
167
  },
171
- // temporary guard here until we're ready for everything to be queried via global API
172
- ...(source && !isDatasetSource(source) ? {source} : {}),
168
+ resource,
173
169
  }),
174
170
  ).pipe(switchMap(() => observable))
175
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 the `client.observable.projects.list` method on the client and returns the result', async () => {
23
+ it('calls `client.observable.request` against `/projects` and returns the result', async () => {
24
24
  const projects = [{id: 'a'}, {id: 'b'}]
25
- const list = vi.fn().mockReturnValue(of(projects))
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(list).toHaveBeenCalledWith({includeMembers: false, organizationId: undefined})
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
- 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 resolveProjects(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.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
  })