@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
@@ -1,20 +1,45 @@
1
- import {type SanityClient} from '@sanity/client'
2
1
  import {createSelector} from 'reselect'
3
- import {combineLatest, distinctUntilChanged, filter, map, of, Subscription, switchMap} from 'rxjs'
2
+ import {
3
+ catchError,
4
+ combineLatest,
5
+ distinctUntilChanged,
6
+ EMPTY,
7
+ filter,
8
+ first,
9
+ map,
10
+ type Observable,
11
+ of,
12
+ Subscription,
13
+ switchMap,
14
+ } from 'rxjs'
4
15
 
5
16
  import {getTokenState} from '../auth/authStore'
6
17
  import {getClient} from '../client/clientStore'
7
- import {bindActionByDataset, type BoundDatasetKey} from '../store/createActionBinder'
8
- import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction'
18
+ import {
19
+ type DocumentResource,
20
+ isCanvasResource,
21
+ isDatasetResource,
22
+ isMediaLibraryResource,
23
+ } from '../config/sanityConfig'
24
+ import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
25
+ import {type SanityInstance} from '../store/createSanityInstance'
26
+ import {
27
+ createStateSourceAction,
28
+ type SelectorContext,
29
+ type StateSource,
30
+ } from '../store/createStateSourceAction'
9
31
  import {defineStore, type StoreContext} from '../store/defineStore'
10
32
  import {type SanityUser} from '../users/types'
11
33
  import {getUserState} from '../users/usersStore'
12
34
  import {createBifurTransport} from './bifurTransport'
13
35
  import {type PresenceLocation, type TransportEvent, type UserPresence} from './types'
14
36
 
37
+ const PRESENCE_API_VERSION = '2026-03-30'
38
+
15
39
  type PresenceStoreState = {
16
40
  locations: Map<string, {userId: string; locations: PresenceLocation[]}>
17
41
  users: Record<string, SanityUser | undefined>
42
+ organizationId?: string
18
43
  }
19
44
 
20
45
  const getInitialState = (): PresenceStoreState => ({
@@ -23,27 +48,40 @@ const getInitialState = (): PresenceStoreState => ({
23
48
  })
24
49
 
25
50
  /** @public */
26
- export const presenceStore = defineStore<PresenceStoreState, BoundDatasetKey>({
51
+ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
27
52
  name: 'presence',
28
53
  getInitialState,
29
- initialize: (context: StoreContext<PresenceStoreState, BoundDatasetKey>) => {
54
+ initialize: (context: StoreContext<PresenceStoreState, BoundResourceKey>) => {
30
55
  const {
31
56
  instance,
32
57
  state,
33
- key: {projectId, dataset},
58
+ key: {resource},
34
59
  } = context
60
+
61
+ if (isMediaLibraryResource(resource)) {
62
+ throw new Error('Presence is not supported for media library resources.')
63
+ }
64
+
35
65
  const sessionId = crypto.randomUUID()
36
66
 
37
- const client = getClient(instance, {
38
- apiVersion: '2022-06-30',
39
- projectId,
40
- dataset,
41
- })
67
+ // Dataset resources must use the project hostname so the socket URL is project-specific.
68
+ // Canvas resources use the global API endpoint via the resource config.
69
+ const client = isDatasetResource(resource)
70
+ ? getClient(instance, {
71
+ apiVersion: PRESENCE_API_VERSION,
72
+ projectId: resource.projectId,
73
+ dataset: resource.dataset,
74
+ useProjectHostname: true,
75
+ })
76
+ : getClient(instance, {
77
+ apiVersion: PRESENCE_API_VERSION,
78
+ resource,
79
+ })
42
80
 
43
81
  const token$ = getTokenState(instance).observable.pipe(distinctUntilChanged())
44
82
 
45
83
  const [incomingEvents$, dispatch] = createBifurTransport({
46
- client: client as SanityClient,
84
+ client,
47
85
  token$,
48
86
  sessionId,
49
87
  })
@@ -81,6 +119,22 @@ export const presenceStore = defineStore<PresenceStoreState, BoundDatasetKey>({
81
119
 
82
120
  dispatch({type: 'rollCall'}).subscribe()
83
121
 
122
+ // Canvas resources need the organizationId to resolve users — fetch it once from the canvas endpoint
123
+ if (isCanvasResource(resource)) {
124
+ const globalClient = getClient(instance, {apiVersion: PRESENCE_API_VERSION})
125
+ subscription.add(
126
+ globalClient.observable
127
+ .request<{organizationId: string}>({
128
+ uri: `/canvases/${resource.canvasId}`,
129
+ tag: 'canvases.get',
130
+ })
131
+ .pipe(catchError(() => EMPTY))
132
+ .subscribe(({organizationId}) => {
133
+ state.set('presence/organizationId', (prev) => ({...prev, organizationId}))
134
+ }),
135
+ )
136
+ }
137
+
84
138
  return () => {
85
139
  dispatch({type: 'disconnect'}).subscribe()
86
140
  subscription.unsubscribe()
@@ -114,13 +168,13 @@ const selectPresence = createSelector(
114
168
  },
115
169
  )
116
170
 
117
- /** @public */
118
- export const getPresence = bindActionByDataset(
171
+ const _getPresence = bindActionByResource(
119
172
  presenceStore,
120
173
  createStateSourceAction({
121
- selector: (context: SelectorContext<PresenceStoreState>, _?): UserPresence[] =>
174
+ selector: (context: SelectorContext<PresenceStoreState>): UserPresence[] =>
122
175
  selectPresence(context.state),
123
- onSubscribe: (context: StoreContext<PresenceStoreState, BoundDatasetKey>, _?) => {
176
+ onSubscribe: (context: StoreContext<PresenceStoreState, BoundResourceKey>) => {
177
+ const resource = context.key.resource
124
178
  const userIds$ = context.state.observable.pipe(
125
179
  map((state) =>
126
180
  Array.from(state.locations.values())
@@ -130,26 +184,34 @@ export const getPresence = bindActionByDataset(
130
184
  distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => v === b[i])),
131
185
  )
132
186
 
133
- const subscription = userIds$
187
+ // For canvas resources, wait for organizationId to be fetched and stored in state.
188
+ // For dataset resources, emit undefined immediately so the stream isn't blocked.
189
+ const organizationId$: Observable<string | undefined> = isCanvasResource(resource)
190
+ ? context.state.observable.pipe(
191
+ map((s) => s.organizationId),
192
+ filter((id): id is string => id !== undefined),
193
+ first(),
194
+ )
195
+ : of(undefined)
196
+
197
+ const subscription = combineLatest([userIds$, organizationId$])
134
198
  .pipe(
135
- switchMap((userIds) => {
199
+ switchMap(([userIds, organizationId]) => {
136
200
  if (userIds.length === 0) {
137
201
  return of([])
138
202
  }
139
203
  const userObservables = userIds.map((userId) =>
140
204
  getUserState(context.instance, {
141
205
  userId,
142
- resourceType: 'project',
143
- projectId: context.key.projectId,
206
+ ...(isDatasetResource(resource)
207
+ ? {resourceType: 'project', projectId: resource.projectId}
208
+ : {resourceType: 'organization', organizationId}),
144
209
  }).pipe(filter((v): v is NonNullable<typeof v> => !!v)),
145
210
  )
146
211
  return combineLatest(userObservables)
147
212
  }),
148
213
  )
149
214
  .subscribe((users) => {
150
- if (!users) {
151
- return
152
- }
153
215
  context.state.set('presence/users', (prevState) => ({
154
216
  ...prevState,
155
217
  users: {
@@ -167,3 +229,13 @@ export const getPresence = bindActionByDataset(
167
229
  },
168
230
  }),
169
231
  )
232
+
233
+ /** @beta */
234
+ export function getPresence(
235
+ instance: SanityInstance,
236
+ params?: {resource?: DocumentResource},
237
+ ): StateSource<UserPresence[]> {
238
+ // bit of a hack to support the old bound action by dataset
239
+ // in reality, this will always be passed a resource
240
+ return _getPresence(instance, params ?? {})
241
+ }
@@ -52,7 +52,7 @@ export function getPreviewState(
52
52
  return {data: null, isPending: current?.isPending ?? false}
53
53
  }
54
54
 
55
- const previewValue = transformProjectionToPreview(instance, current.data, options.source)
55
+ const previewValue = transformProjectionToPreview(instance, current.data, options.resource)
56
56
 
57
57
  return {
58
58
  data: previewValue,
@@ -156,7 +156,7 @@ describe('transformProjectionToPreview', () => {
156
156
  })
157
157
  })
158
158
 
159
- it('calls getClient with the provided source', async () => {
159
+ it('calls getClient with the provided resource', async () => {
160
160
  const projectionResult: PreviewQueryResult = {
161
161
  _id: 'doc1',
162
162
  _type: 'article',
@@ -166,14 +166,14 @@ describe('transformProjectionToPreview', () => {
166
166
  media: null,
167
167
  }
168
168
 
169
- const source = {mediaLibraryId: 'test-library'}
169
+ const resource = {mediaLibraryId: 'test-library'}
170
170
 
171
- transformProjectionToPreview(instance, projectionResult, source)
171
+ transformProjectionToPreview(instance, projectionResult, resource)
172
172
 
173
173
  const {getClient} = await import('../client/clientStore')
174
174
  expect(getClient).toHaveBeenCalledWith(instance, {
175
175
  apiVersion: 'v2025-05-06',
176
- source,
176
+ resource,
177
177
  })
178
178
  })
179
179
  })
@@ -1,10 +1,10 @@
1
1
  import {type SanityClient} from '@sanity/client'
2
2
  import {createImageUrlBuilder} from '@sanity/image-url'
3
- import {isObject} from 'lodash-es'
4
3
 
5
4
  import {getClient} from '../client/clientStore'
6
- import {type DocumentSource, isDatasetSource} from '../config/sanityConfig'
5
+ import {type DocumentResource} from '../config/sanityConfig'
7
6
  import {type SanityInstance} from '../store/createSanityInstance'
7
+ import {isObject} from '../utils/object'
8
8
  import {SUBTITLE_CANDIDATES, TITLE_CANDIDATES} from './previewConstants'
9
9
  import {type PreviewQueryResult, type PreviewValue} from './types'
10
10
 
@@ -66,22 +66,21 @@ function findFirstDefined(
66
66
  *
67
67
  * @param projectionResult - The raw projection result from GROQ
68
68
  * @param instance - The Sanity instance to use for client configuration
69
- * @param source - Data source for the preview
69
+ * @param resource - Data resource for the preview
70
70
  * @internal
71
71
  */
72
72
  export function transformProjectionToPreview(
73
73
  instance: SanityInstance,
74
74
  projectionResult: PreviewQueryResult,
75
- source?: DocumentSource,
75
+ resource?: DocumentResource,
76
76
  ): PreviewValue {
77
77
  const title = findFirstDefined(TITLE_CANDIDATES, projectionResult.titleCandidates)
78
78
  const subtitle = findFirstDefined(SUBTITLE_CANDIDATES, projectionResult.subtitleCandidates, title)
79
79
 
80
- // Get a client for the source (if provided) or use the instance config
80
+ // Get a client for the resource (if provided) or use the instance config
81
81
  const client = getClient(instance, {
82
82
  apiVersion: API_VERSION,
83
- // TODO: remove in v3 when we're ready for everything to be queried via source
84
- source: source && !isDatasetSource(source) ? source : undefined,
83
+ resource,
85
84
  })
86
85
 
87
86
  return {
@@ -30,7 +30,11 @@ export async function resolvePreview(
30
30
  }
31
31
 
32
32
  // Transform to preview format
33
- const previewValue = transformProjectionToPreview(instance, projectionResult.data, options.source)
33
+ const previewValue = transformProjectionToPreview(
34
+ instance,
35
+ projectionResult.data,
36
+ options.resource,
37
+ )
34
38
 
35
39
  return {
36
40
  data: previewValue,
@@ -0,0 +1,93 @@
1
+ import {expectTypeOf, test} from 'vitest'
2
+
3
+ import {type SanityInstance} from '../store/createSanityInstance'
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'
12
+
13
+ const instance = {} as SanityInstance
14
+
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>
56
+ >()
57
+ })
58
+
59
+ test('getProjectState — default call returns members + features StateSource', () => {
60
+ expectTypeOf(getProjectState(instance)).toEqualTypeOf<
61
+ StateSource<Project<true, true> | undefined>
62
+ >()
63
+ })
64
+
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>>()
69
+ })
70
+
71
+ test('Project — wide boolean for IncludeMembers makes members optional', () => {
72
+ expectTypeOf<Project<boolean, true>>().toEqualTypeOf<
73
+ ProjectBase & {members?: ProjectMember[]} & {features: string[]}
74
+ >()
75
+ expectTypeOf<Pick<Project<boolean, true>, 'members'>>().toEqualTypeOf<{
76
+ members?: ProjectMember[]
77
+ }>()
78
+ })
79
+
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
+ >()
93
+ })
@@ -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
- it('calls the `client.observable.projects.getById` method on the client and returns the result', async () => {
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 getById = vi.fn().mockReturnValue(of(project))
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(getById).toHaveBeenCalledWith('a')
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
  })