@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -61,7 +61,7 @@
61
61
  "@sanity/telemetry": "^1.1.0",
62
62
  "@sanity/types": "^5.2.0",
63
63
  "groq": "3.88.1-typegen-experimental.0",
64
- "groq-js": "^1.19.0",
64
+ "groq-js": "^1.30.1",
65
65
  "reselect": "^5.1.1",
66
66
  "rxjs": "^7.8.2",
67
67
  "zustand": "^5.0.12"
@@ -70,21 +70,19 @@
70
70
  "@sanity/browserslist-config": "^1.0.5",
71
71
  "@sanity/pkg-utils": "^8.1.29",
72
72
  "@sanity/prettier-config": "^1.0.6",
73
- "@vitest/coverage-v8": "3.2.4",
73
+ "@types/node": "^22.19.1",
74
+ "@vitest/coverage-v8": "4.1.5",
74
75
  "eslint": "^9.22.0",
75
76
  "prettier": "^3.7.3",
76
77
  "rollup-plugin-visualizer": "^5.14.0",
77
78
  "typescript": "^5.8.3",
78
79
  "vite": "^7.0.0",
79
- "vitest": "^3.2.4",
80
+ "vitest": "^4.1.4",
80
81
  "@repo/config-eslint": "0.0.0",
81
- "@repo/tsconfig": "0.0.1",
82
82
  "@repo/package.bundle": "3.82.0",
83
+ "@repo/config-test": "0.0.1",
83
84
  "@repo/package.config": "0.0.1",
84
- "@repo/config-test": "0.0.1"
85
- },
86
- "engines": {
87
- "node": ">=20.19"
85
+ "@repo/tsconfig": "0.0.1"
88
86
  },
89
87
  "publishConfig": {
90
88
  "access": "public"
@@ -12,3 +12,4 @@ export {getQueryKey, parseQueryKey} from '../query/queryStore' // only used for
12
12
  export {getTelemetryManager, initTelemetry, trackHookMounted} from '../telemetry/initTelemetry'
13
13
  export {getUsersKey, parseUsersKey} from '../users/reducers' // only used for memoizing in React, not needed for actual functionality
14
14
  export {createGroqSearchFilter} from '../utils/createGroqSearchFilter'
15
+ export {isDeepEqual, pickProperties} from '../utils/object'
@@ -157,6 +157,20 @@ export {type JsonMatch} from '../document/patchOperations'
157
157
  export {type DocumentPermissionsResult, type PermissionDeniedReason} from '../document/permissions'
158
158
  export type {FavoriteStatusResponse} from '../favorites/favorites'
159
159
  export {getFavoritesState, resolveFavoritesState} from '../favorites/favorites'
160
+ export {
161
+ getOrganizationState,
162
+ type Organization,
163
+ type OrganizationBase,
164
+ type OrganizationMember,
165
+ type OrganizationOptions,
166
+ resolveOrganization,
167
+ } from '../organization/organization'
168
+ export {
169
+ getOrganizationsState,
170
+ type Organizations,
171
+ type OrganizationsOptions,
172
+ resolveOrganizations,
173
+ } from '../organizations/organizations'
160
174
  export {getPresence} from '../presence/presenceStore'
161
175
  export type {
162
176
  DisconnectEvent,
@@ -178,11 +192,20 @@ export type {
178
192
  ValuePending,
179
193
  } from '../preview/types'
180
194
  export {type OrgVerificationResult} from '../project/organizationVerification'
181
- export {getProjectState, resolveProject} from '../project/project'
195
+ export {
196
+ getProjectState,
197
+ type Project,
198
+ type ProjectBase,
199
+ type ProjectMember,
200
+ type ProjectMemberRole,
201
+ type ProjectMetadata,
202
+ type ProjectOptions,
203
+ resolveProject,
204
+ } from '../project/project'
182
205
  export {getProjectionState} from '../projection/getProjectionState'
183
206
  export {resolveProjection} from '../projection/resolveProjection'
184
207
  export {type ProjectionValuePending, type ValidProjection} from '../projection/types'
185
- export {getProjectsState, resolveProjects} from '../projects/projects'
208
+ export {getProjectsState, type ProjectsOptions, resolveProjects} from '../projects/projects'
186
209
  export {
187
210
  getQueryKey,
188
211
  getQueryState,
@@ -2,6 +2,7 @@ import {type SanityClient} from '@sanity/client'
2
2
  import {from, Observable, switchMap} from 'rxjs'
3
3
 
4
4
  import {getClientState} from '../client/clientStore'
5
+ import {type DocumentResource} from '../config/sanityConfig'
5
6
  import {type SanityInstance} from '../store/createSanityInstance'
6
7
 
7
8
  const API_VERSION = 'vX'
@@ -58,12 +59,11 @@ export type AgentPatchResult = Awaited<ReturnType<SanityClient['agent']['action'
58
59
  export function agentGenerate(
59
60
  instance: SanityInstance,
60
61
  options: AgentGenerateOptions,
62
+ resource?: DocumentResource,
61
63
  ): AgentGenerateResult {
62
- return getClientState(instance, {
63
- apiVersion: API_VERSION,
64
- projectId: instance.config.projectId,
65
- dataset: instance.config.dataset,
66
- }).observable.pipe(switchMap((client) => client.observable.agent.action.generate(options)))
64
+ return getClientState(instance, {apiVersion: API_VERSION, resource}).observable.pipe(
65
+ switchMap((client) => client.observable.agent.action.generate(options)),
66
+ )
67
67
  }
68
68
 
69
69
  /**
@@ -76,12 +76,11 @@ export function agentGenerate(
76
76
  export function agentTransform(
77
77
  instance: SanityInstance,
78
78
  options: AgentTransformOptions,
79
+ resource?: DocumentResource,
79
80
  ): AgentTransformResult {
80
- return getClientState(instance, {
81
- apiVersion: API_VERSION,
82
- projectId: instance.config.projectId,
83
- dataset: instance.config.dataset,
84
- }).observable.pipe(switchMap((client) => client.observable.agent.action.transform(options)))
81
+ return getClientState(instance, {apiVersion: API_VERSION, resource}).observable.pipe(
82
+ switchMap((client) => client.observable.agent.action.transform(options)),
83
+ )
85
84
  }
86
85
 
87
86
  /**
@@ -94,12 +93,11 @@ export function agentTransform(
94
93
  export function agentTranslate(
95
94
  instance: SanityInstance,
96
95
  options: AgentTranslateOptions,
96
+ resource?: DocumentResource,
97
97
  ): AgentTranslateResult {
98
- return getClientState(instance, {
99
- apiVersion: API_VERSION,
100
- projectId: instance.config.projectId,
101
- dataset: instance.config.dataset,
102
- }).observable.pipe(switchMap((client) => client.observable.agent.action.translate(options)))
98
+ return getClientState(instance, {apiVersion: API_VERSION, resource}).observable.pipe(
99
+ switchMap((client) => client.observable.agent.action.translate(options)),
100
+ )
103
101
  }
104
102
 
105
103
  /**
@@ -112,12 +110,11 @@ export function agentTranslate(
112
110
  export function agentPrompt(
113
111
  instance: SanityInstance,
114
112
  options: AgentPromptOptions,
113
+ resource?: DocumentResource,
115
114
  ): Observable<AgentPromptResult> {
116
- return getClientState(instance, {
117
- apiVersion: API_VERSION,
118
- projectId: instance.config.projectId,
119
- dataset: instance.config.dataset,
120
- }).observable.pipe(switchMap((client) => from(client.agent.action.prompt(options))))
115
+ return getClientState(instance, {apiVersion: API_VERSION, resource}).observable.pipe(
116
+ switchMap((client) => from(client.agent.action.prompt(options))),
117
+ )
121
118
  }
122
119
 
123
120
  /**
@@ -130,10 +127,9 @@ export function agentPrompt(
130
127
  export function agentPatch(
131
128
  instance: SanityInstance,
132
129
  options: AgentPatchOptions,
130
+ resource?: DocumentResource,
133
131
  ): Observable<AgentPatchResult> {
134
- return getClientState(instance, {
135
- apiVersion: API_VERSION,
136
- projectId: instance.config.projectId,
137
- dataset: instance.config.dataset,
138
- }).observable.pipe(switchMap((client) => from(client.agent.action.patch(options))))
132
+ return getClientState(instance, {apiVersion: API_VERSION, resource}).observable.pipe(
133
+ switchMap((client) => from(client.agent.action.patch(options))),
134
+ )
139
135
  }
@@ -191,29 +191,7 @@ describe('clientStore', () => {
191
191
  })
192
192
 
193
193
  describe('resource handling', () => {
194
- it('should create client when resource is provided', () => {
195
- const client = getClient(instance, {
196
- apiVersion: '2024-11-12',
197
- resource: {projectId: 'source-project', dataset: 'source-dataset'},
198
- })
199
-
200
- expect(vi.mocked(createClient)).toHaveBeenCalledWith(
201
- expect.objectContaining({
202
- apiVersion: '2024-11-12',
203
- resource: {type: 'dataset', id: 'source-project.source-dataset'},
204
- }),
205
- )
206
- // Client should be projectless - no projectId/dataset in config
207
- expect(client.config()).not.toHaveProperty('projectId')
208
- expect(client.config()).not.toHaveProperty('dataset')
209
- expect(client.config()).toEqual(
210
- expect.objectContaining({
211
- resource: {type: 'dataset', id: 'source-project.source-dataset'},
212
- }),
213
- )
214
- })
215
-
216
- it('should create resource when resource has array resourceId and be projectless', () => {
194
+ it('should create resource when media library resource is provided and be projectless', () => {
217
195
  const client = getClient(instance, {
218
196
  apiVersion: '2024-11-12',
219
197
  resource: {mediaLibraryId: 'media-lib-123'},
@@ -257,38 +235,24 @@ describe('clientStore', () => {
257
235
  )
258
236
  })
259
237
 
260
- it('should create projectless client when resource is provided, ignoring instance config', () => {
238
+ it('should transform dataset resource to project-based config for now', () => {
261
239
  const client = getClient(instance, {
262
240
  apiVersion: '2024-11-12',
263
241
  resource: {projectId: 'source-project', dataset: 'source-dataset'},
264
242
  })
265
243
 
266
- // Client should be projectless - resource takes precedence, instance config is ignored
267
- expect(client.config()).not.toHaveProperty('projectId')
268
- expect(client.config()).not.toHaveProperty('dataset')
269
- expect(client.config()).toEqual(
244
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
270
245
  expect.objectContaining({
271
- resource: {type: 'dataset', id: 'source-project.source-dataset'},
246
+ projectId: 'source-project',
247
+ dataset: 'source-dataset',
272
248
  }),
273
249
  )
274
- })
275
-
276
- it('should warn when both resource and explicit projectId/dataset are provided', () => {
277
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
278
- const client = getClient(instance, {
279
- apiVersion: '2024-11-12',
280
- resource: {projectId: 'source-project', dataset: 'source-dataset'},
281
- projectId: 'explicit-project',
282
- dataset: 'explicit-dataset',
283
- })
284
-
285
- expect(consoleSpy).toHaveBeenCalledWith(
286
- 'Both resource and explicit projectId/dataset are provided. The resource will be used and projectId/dataset will be ignored.',
250
+ expect(client.config()).toEqual(
251
+ expect.objectContaining({
252
+ projectId: 'source-project',
253
+ dataset: 'source-dataset',
254
+ }),
287
255
  )
288
- // Client should still be projectless despite explicit projectId/dataset
289
- expect(client.config()).not.toHaveProperty('projectId')
290
- expect(client.config()).not.toHaveProperty('dataset')
291
- consoleSpy.mockRestore()
292
256
  })
293
257
 
294
258
  it('should create different clients for different resources', () => {
@@ -178,24 +178,23 @@ export const getClient = bindActionGlobally(
178
178
 
179
179
  const tokenFromState = state.get().token
180
180
  const {clients, authMethod} = state.get()
181
+ let projectId = options.projectId ?? instance.config.projectId
182
+ let dataset = options.dataset ?? instance.config.dataset
181
183
 
182
184
  let resource: ClientConfig['resource'] | undefined
183
185
 
184
186
  if (options.resource) {
185
- if (isDatasetResource(options.resource)) {
186
- resource = {
187
- type: 'dataset',
188
- id: `${options.resource.projectId}.${options.resource.dataset}`,
189
- }
190
- } else if (isMediaLibraryResource(options.resource)) {
187
+ if (isMediaLibraryResource(options.resource)) {
191
188
  resource = {type: 'media-library', id: options.resource.mediaLibraryId}
192
189
  } else if (isCanvasResource(options.resource)) {
193
190
  resource = {type: 'canvas', id: options.resource.canvasId}
191
+ } else if (isDatasetResource(options.resource)) {
192
+ // use project-based routes for datasets to avoid existing CORS and Studio auth cookie issues
193
+ projectId = options.resource.projectId
194
+ dataset = options.resource.dataset
194
195
  }
195
196
  }
196
197
 
197
- const projectId = options.projectId ?? instance.config.projectId
198
- const dataset = options.dataset ?? instance.config.dataset
199
198
  const apiHost = options.apiHost ?? instance.config.auth?.apiHost ?? getStagingApiHost()
200
199
 
201
200
  const effectiveOptions: ClientConfig & {apiVersion: string} = {
@@ -213,12 +212,6 @@ export const getClient = bindActionGlobally(
213
212
  // The client code itself will ignore the non-resource config, so we do this to prevent confusing the user.
214
213
  // (ref: https://github.com/sanity-io/client/blob/5c23f81f5ab93a53f5b22b39845c867988508d84/src/data/dataMethods.ts#L691)
215
214
  if (resource) {
216
- if (options.projectId || options.dataset) {
217
- // eslint-disable-next-line no-console
218
- console.warn(
219
- 'Both resource and explicit projectId/dataset are provided. The resource will be used and projectId/dataset will be ignored.',
220
- )
221
- }
222
215
  delete effectiveOptions.projectId
223
216
  delete effectiveOptions.dataset
224
217
  }
@@ -27,14 +27,17 @@ describe('getOrCreateNode', () => {
27
27
  dataset: 'test-dataset',
28
28
  })
29
29
  let state: ReturnType<typeof createStoreState<ComlinkNodeState>>
30
- let mockNode: Partial<Node<WindowMessage, FrameMessage>> & {
30
+ let mockNode: {
31
31
  start: ReturnType<typeof vi.fn>
32
32
  stop: ReturnType<typeof vi.fn>
33
+ onStatus: ReturnType<typeof vi.fn>
33
34
  }
34
35
 
35
36
  beforeEach(() => {
36
37
  mockNode = {start: vi.fn(), stop: vi.fn(), onStatus: vi.fn()}
37
- vi.mocked(comlink.createNode).mockReturnValue(mockNode as Node<WindowMessage, FrameMessage>)
38
+ vi.mocked(comlink.createNode).mockReturnValue(
39
+ mockNode as unknown as Node<WindowMessage, FrameMessage>,
40
+ )
38
41
  state = createStoreState<ComlinkNodeState>({nodes: new Map(), subscriptions: new Map()})
39
42
  vi.clearAllMocks()
40
43
  })
@@ -15,7 +15,7 @@ const nodeConfig = {
15
15
  describe('releaseNode', () => {
16
16
  let instance: SanityInstance
17
17
  let state: ReturnType<typeof createStoreState<ComlinkNodeState>>
18
- let mockNode: Partial<Node<WindowMessage, FrameMessage>> & {
18
+ let mockNode: {
19
19
  start: ReturnType<typeof vi.fn>
20
20
  stop: ReturnType<typeof vi.fn>
21
21
  onStatus: ReturnType<typeof vi.fn>
@@ -39,7 +39,7 @@ describe('releaseNode', () => {
39
39
  // Set up a node in the state
40
40
  const nodes = new Map()
41
41
  nodes.set('test-node', {
42
- node: mockNode as Node<WindowMessage, FrameMessage>,
42
+ node: mockNode as unknown as Node<WindowMessage, FrameMessage>,
43
43
  options: nodeConfig,
44
44
  })
45
45
  state.set('setup', {nodes})
@@ -58,7 +58,7 @@ describe('releaseNode', () => {
58
58
  const statusUnsub = vi.fn()
59
59
  const nodes = new Map()
60
60
  nodes.set('test-node', {
61
- node: mockNode as Node<WindowMessage, FrameMessage>,
61
+ node: mockNode as unknown as Node<WindowMessage, FrameMessage>,
62
62
  options: nodeConfig,
63
63
  statusUnsub,
64
64
  })
@@ -125,7 +125,6 @@ export interface DocumentHandle<
125
125
  export interface SanityConfig extends DatasetHandle, PerspectiveHandle {
126
126
  /**
127
127
  * Authentication configuration for the instance
128
- * @remarks Merged with parent configurations when using createChild
129
128
  */
130
129
  auth?: AuthConfig
131
130
  /**
@@ -396,8 +396,7 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
396
396
  withLatestFrom(
397
397
  getClientState(instance, {
398
398
  apiVersion: API_VERSION,
399
- // TODO: remove in v3 when we're ready for everything to be queried via resource
400
- resource: resource && !isDatasetResource(resource) ? resource : undefined,
399
+ resource,
401
400
  }).observable,
402
401
  ),
403
402
  concatMap(([outgoing, client]) => {
@@ -520,11 +519,7 @@ const subscribeToClientAndFetchDatasetAcl = ({
520
519
  state,
521
520
  key: {resource},
522
521
  }: StoreContext<DocumentStoreState, BoundResourceKey>) => {
523
- const clientOptions: ClientOptions = {apiVersion: API_VERSION}
524
- // TODO: remove in v3 when we're ready for everything to be queried via resource
525
- if (resource && !isDatasetResource(resource)) {
526
- clientOptions.resource = resource
527
- }
522
+ const clientOptions: ClientOptions = {apiVersion: API_VERSION, resource}
528
523
 
529
524
  let uri: string
530
525
  if (resource && isDatasetResource(resource)) {
@@ -14,7 +14,7 @@ import {
14
14
  } from 'rxjs'
15
15
 
16
16
  import {getClientState} from '../client/clientStore'
17
- import {type DocumentResource, isDatasetResource} from '../config/sanityConfig'
17
+ import {type DocumentResource} from '../config/sanityConfig'
18
18
  import {type SanityInstance} from '../store/createSanityInstance'
19
19
 
20
20
  const API_VERSION = 'v2025-05-06'
@@ -31,8 +31,7 @@ export function createSharedListener(
31
31
  const dispose$ = new Subject<void>()
32
32
  const events$ = getClientState(instance, {
33
33
  apiVersion: API_VERSION,
34
- // TODO: remove in v3 when we're ready for everything to be queried via resource
35
- resource: resource && !isDatasetResource(resource) ? resource : undefined,
34
+ resource,
36
35
  }).observable.pipe(
37
36
  switchMap((client) =>
38
37
  // TODO: it seems like the client.listen method is not emitting disconnected
@@ -72,8 +71,7 @@ export function createFetchDocument(instance: SanityInstance, resource?: Documen
72
71
  return function (documentId: string): Observable<SanityDocument | null> {
73
72
  return getClientState(instance, {
74
73
  apiVersion: API_VERSION,
75
- // TODO: remove in v3 when we're ready for everything to be queried via resource
76
- resource: resource && !isDatasetResource(resource) ? resource : undefined,
74
+ resource,
77
75
  }).observable.pipe(
78
76
  switchMap((client) => {
79
77
  // creates a observable request to the /doc/{documentId} endpoint for a given document id
@@ -0,0 +1,102 @@
1
+ import {expectTypeOf, test} from 'vitest'
2
+
3
+ import {type SanityInstance} from '../store/createSanityInstance'
4
+ import {type StateSource} from '../store/createStateSourceAction'
5
+ import {
6
+ getOrganizationState,
7
+ type Organization,
8
+ type OrganizationBase,
9
+ type OrganizationMember,
10
+ resolveOrganization,
11
+ } from './organization'
12
+
13
+ const instance = {} as SanityInstance
14
+
15
+ test('resolveOrganization — default call: members and features both omitted by default', () => {
16
+ expectTypeOf(resolveOrganization(instance, {organizationId: 'org_1'})).resolves.toEqualTypeOf<
17
+ Organization<false, false>
18
+ >()
19
+ type Result = Awaited<ReturnType<typeof resolveOrganization<false, false>>>
20
+ expectTypeOf<Result['id']>().toEqualTypeOf<string>()
21
+ })
22
+
23
+ test('resolveOrganization — includeMembers: true adds members to the type', () => {
24
+ expectTypeOf(
25
+ resolveOrganization(instance, {organizationId: 'org_1', includeMembers: true}),
26
+ ).resolves.toEqualTypeOf<Organization<true, false>>()
27
+ type Result = Awaited<ReturnType<typeof resolveOrganization<true, false>>>
28
+ expectTypeOf<Result['members']>().toEqualTypeOf<OrganizationMember[]>()
29
+ })
30
+
31
+ test('resolveOrganization — includeFeatures: true adds features to the type', () => {
32
+ expectTypeOf(
33
+ resolveOrganization(instance, {organizationId: 'org_1', includeFeatures: true}),
34
+ ).resolves.toEqualTypeOf<Organization<false, true>>()
35
+ })
36
+
37
+ test('resolveOrganization — both flags true → both arrays present', () => {
38
+ expectTypeOf(
39
+ resolveOrganization(instance, {
40
+ organizationId: 'org_1',
41
+ includeMembers: true,
42
+ includeFeatures: true,
43
+ }),
44
+ ).resolves.toEqualTypeOf<Organization<true, true>>()
45
+ })
46
+
47
+ test('resolveOrganization — rejects non-boolean flag values', () => {
48
+ // @ts-expect-error — includeMembers must be a boolean
49
+ void resolveOrganization(instance, {organizationId: 'org_1', includeMembers: 'yes'})
50
+ })
51
+
52
+ test('resolveOrganization — requires organizationId', () => {
53
+ // @ts-expect-error — organizationId is required
54
+ void resolveOrganization(instance, {})
55
+ })
56
+
57
+ test('resolveOrganization — non-literal boolean flag makes members optional', () => {
58
+ const includeMembers = false as boolean
59
+ expectTypeOf(
60
+ resolveOrganization(instance, {organizationId: 'org_1', includeMembers}),
61
+ ).resolves.toEqualTypeOf<Organization<boolean, false>>()
62
+ })
63
+
64
+ test('getOrganizationState — default call returns bare-base StateSource', () => {
65
+ expectTypeOf(getOrganizationState(instance, {organizationId: 'org_1'})).toEqualTypeOf<
66
+ StateSource<Organization<false, false> | undefined>
67
+ >()
68
+ })
69
+
70
+ test('getOrganizationState — both flags true narrows the StateSource value type', () => {
71
+ expectTypeOf(
72
+ getOrganizationState(instance, {
73
+ organizationId: 'org_1',
74
+ includeMembers: true,
75
+ includeFeatures: true,
76
+ }),
77
+ ).toEqualTypeOf<StateSource<Organization<true, true> | undefined>>()
78
+ })
79
+
80
+ test('Organization — wide boolean for IncludeMembers makes members optional', () => {
81
+ expectTypeOf<Organization<boolean, true>>().toEqualTypeOf<
82
+ OrganizationBase & {members?: OrganizationMember[]} & {features: string[]}
83
+ >()
84
+ expectTypeOf<Pick<Organization<boolean, true>, 'members'>>().toEqualTypeOf<{
85
+ members?: OrganizationMember[]
86
+ }>()
87
+ })
88
+
89
+ test('Organization — wide boolean for IncludeFeatures makes features optional', () => {
90
+ expectTypeOf<Organization<true, boolean>>().toEqualTypeOf<
91
+ OrganizationBase & {members: OrganizationMember[]} & {features?: string[]}
92
+ >()
93
+ expectTypeOf<Pick<Organization<true, boolean>, 'features'>>().toEqualTypeOf<{
94
+ features?: string[]
95
+ }>()
96
+ })
97
+
98
+ test('Organization — both wide booleans make both fields optional', () => {
99
+ expectTypeOf<Organization<boolean, boolean>>().toEqualTypeOf<
100
+ OrganizationBase & {members?: OrganizationMember[]} & {features?: string[]}
101
+ >()
102
+ })
@@ -0,0 +1,138 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {of} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, it} from 'vitest'
4
+
5
+ import {getClientState} from '../client/clientStore'
6
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
+ import {type StateSource} from '../store/createStateSourceAction'
8
+ import {getOrganizationCacheKey, resolveOrganization} from './organization'
9
+
10
+ vi.mock('../client/clientStore')
11
+
12
+ describe('organization', () => {
13
+ let instance: SanityInstance
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 `/organizations/<id>` and returns the result', async () => {
24
+ const organization = {id: 'org_1'}
25
+ const request = vi.fn().mockReturnValue(of(organization))
26
+
27
+ const mockClient = {
28
+ observable: {request} as unknown as SanityClient['observable'],
29
+ } as SanityClient
30
+
31
+ vi.mocked(getClientState).mockReturnValue({
32
+ observable: of(mockClient),
33
+ } as StateSource<SanityClient>)
34
+
35
+ const result = await resolveOrganization(instance, {organizationId: 'org_1'})
36
+ expect(result).toEqual(organization)
37
+ expect(request).toHaveBeenCalledWith({
38
+ uri: '/organizations/org_1',
39
+ query: {includeMembers: 'false', includeFeatures: 'false'},
40
+ tag: 'organization.get',
41
+ })
42
+ })
43
+
44
+ it('serializes query params (booleans → strings) and respects flags', async () => {
45
+ const request = vi.fn().mockReturnValue(of({id: 'org_1'}))
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 resolveOrganization(instance, {
55
+ organizationId: 'org_1',
56
+ includeMembers: true,
57
+ includeFeatures: true,
58
+ })
59
+
60
+ expect(request).toHaveBeenCalledWith({
61
+ uri: '/organizations/org_1',
62
+ query: {
63
+ includeMembers: 'true',
64
+ includeFeatures: 'true',
65
+ },
66
+ tag: 'organization.get',
67
+ })
68
+ })
69
+
70
+ it('throws when no organizationId is provided', async () => {
71
+ await expect(resolveOrganization(instance, {organizationId: ''} as never)).rejects.toThrow(
72
+ 'An organizationId is required to use the organization API.',
73
+ )
74
+ })
75
+ })
76
+
77
+ describe('organization cache key generation', () => {
78
+ let instance: SanityInstance
79
+
80
+ beforeEach(() => {
81
+ instance = createSanityInstance({})
82
+ })
83
+
84
+ afterEach(() => {
85
+ instance.dispose()
86
+ })
87
+
88
+ it('default call excludes :members and :features (both default-false)', () => {
89
+ expect(getOrganizationCacheKey(instance, {organizationId: 'org_1'})).toBe('organization:org_1')
90
+ })
91
+
92
+ it('treats undefined and the matching default as the same key', () => {
93
+ expect(getOrganizationCacheKey(instance, {organizationId: 'org_1'})).toBe(
94
+ getOrganizationCacheKey(instance, {
95
+ organizationId: 'org_1',
96
+ includeMembers: false,
97
+ includeFeatures: false,
98
+ }),
99
+ )
100
+ })
101
+
102
+ it('explicit includeMembers: true appends :members', () => {
103
+ expect(getOrganizationCacheKey(instance, {organizationId: 'org_1', includeMembers: true})).toBe(
104
+ 'organization:org_1:members',
105
+ )
106
+ })
107
+
108
+ it('explicit includeFeatures: true appends :features', () => {
109
+ expect(
110
+ getOrganizationCacheKey(instance, {organizationId: 'org_1', includeFeatures: true}),
111
+ ).toBe('organization:org_1:features')
112
+ })
113
+
114
+ it('combines all segments in order', () => {
115
+ expect(
116
+ getOrganizationCacheKey(instance, {
117
+ organizationId: 'org_1',
118
+ includeMembers: true,
119
+ includeFeatures: true,
120
+ }),
121
+ ).toBe('organization:org_1:members:features')
122
+ })
123
+
124
+ it('produces distinct keys for each meaningful option permutation', () => {
125
+ const keys = new Set([
126
+ getOrganizationCacheKey(instance, {organizationId: 'org_1'}),
127
+ getOrganizationCacheKey(instance, {organizationId: 'org_1', includeMembers: true}),
128
+ getOrganizationCacheKey(instance, {organizationId: 'org_1', includeFeatures: true}),
129
+ getOrganizationCacheKey(instance, {
130
+ organizationId: 'org_1',
131
+ includeMembers: true,
132
+ includeFeatures: true,
133
+ }),
134
+ getOrganizationCacheKey(instance, {organizationId: 'org_2'}),
135
+ ])
136
+ expect(keys.size).toBe(5)
137
+ })
138
+ })