@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.
Files changed (45) hide show
  1. package/dist/_chunks-dts/utils.d.ts +200 -28
  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 +7 -14
  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 +168 -88
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -9
  13. package/src/_exports/_internal.ts +1 -0
  14. package/src/_exports/index.ts +25 -2
  15. package/src/agent/agentActions.ts +21 -25
  16. package/src/client/clientStore.test.ts +10 -46
  17. package/src/client/clientStore.ts +7 -14
  18. package/src/comlink/node/actions/getOrCreateNode.test.ts +5 -2
  19. package/src/comlink/node/actions/releaseNode.test.ts +3 -3
  20. package/src/config/sanityConfig.ts +0 -1
  21. package/src/document/documentStore.ts +2 -7
  22. package/src/document/sharedListener.ts +3 -5
  23. package/src/organization/organization.test-d.ts +102 -0
  24. package/src/organization/organization.test.ts +138 -0
  25. package/src/organization/organization.ts +166 -0
  26. package/src/organizations/organizations.test-d.ts +77 -0
  27. package/src/organizations/organizations.test.ts +150 -0
  28. package/src/organizations/organizations.ts +132 -0
  29. package/src/presence/presenceStore.test.ts +5 -5
  30. package/src/preview/previewProjectionUtils.ts +2 -3
  31. package/src/project/project.test-d.ts +93 -0
  32. package/src/project/project.test.ts +108 -10
  33. package/src/project/project.ts +152 -26
  34. package/src/projection/subscribeToStateAndFetchBatches.ts +4 -9
  35. package/src/projects/projects.test-d.ts +38 -0
  36. package/src/projects/projects.test.ts +104 -38
  37. package/src/projects/projects.ts +74 -14
  38. package/src/query/queryStore.ts +2 -3
  39. package/src/releases/releasesStore.test.ts +1 -1
  40. package/src/releases/releasesStore.ts +2 -2
  41. package/src/store/createSanityInstance.ts +3 -3
  42. package/src/telemetry/devMode.test.ts +8 -0
  43. package/src/telemetry/devMode.ts +10 -9
  44. package/src/telemetry/initTelemetry.test.ts +0 -17
  45. 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
- 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
  })
@@ -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
@@ -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
- // temporary guard here until we're ready for everything to be queried via global API
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
- // temporary guard here until we're ready for everything to be queried via global API in v3
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
- // temporary guard here until we're ready for everything to be queried via global API
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
- // temporary guard here until we're ready for everything to be queried via global API
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 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
  })