Skip to content

Commit 92340b9

Browse files
judofyrcngonzalez
andauthored
feat: Introduce "source" concept (#626)
* feat: introduce "source" concept The following introduces a "source" concept that can be used to query different types of resources. The long-term vision here is: - `SanityInstance` should no longer be an inherited tree structure. This will rather be a single object which contains global information (e.g. token). There will be no re-use of any state across `SanityInstance`. - All hooks/functions which works on documents will also accept a `source` parameter. This is a single parameter (instead of the `projectId`/`dataset`) tuple that can easily be passed around. If you're making higher-order helpers you just need to accept a single source object. - There are top-level functions which constructs these `source` objects. In the future these objects will also contain type information. These should also be owned by `@sanity/client`. In this PR we're mapping it to the existing experimental resource API, but the intention is for `@sanity/client` to provide a proper source-based API which we're re-exporting/using here in SDK. - We'll introduce a new "default source" context as well. This is _outside_ of the `SanityInstance` so that you can change it. - There will be no special support for placing multiple sources in the context. The user can always define their own context if they want to have access to multiple `source` objects. This gives them more flexibility in how to structure their applications. - If you have fully static data sources you can always hard-code them in single file: `export const PRODUCTS = datasetSource(projectId, "datasets")`. * refactor: enforce explicit client config and add tests to achieve coverage --------- Co-authored-by: Carolina Gonzalez <carolina@sanity.io>
1 parent cb70f89 commit 92340b9

File tree

8 files changed

+298
-11
lines changed

8 files changed

+298
-11
lines changed

apps/kitchensink-react/src/AppRoutes.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {AgentActionsRoute} from './routes/AgentActionsRoute'
1919
import {DashboardContextRoute} from './routes/DashboardContextRoute'
2020
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
2121
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
22+
import {MediaLibraryRoute} from './routes/MediaLibraryRoute'
2223
import {PerspectivesRoute} from './routes/PerspectivesRoute'
2324
import {ProjectsRoute} from './routes/ProjectsRoute'
2425
import {ReleasesRoute} from './routes/releases/ReleasesRoute'
@@ -74,6 +75,10 @@ const documentCollectionRoutes = [
7475
path: 'presence',
7576
element: <PresenceRoute />,
7677
},
78+
{
79+
path: 'media-library',
80+
element: <MediaLibraryRoute />,
81+
},
7782
]
7883

7984
const dashboardInteractionRoutes = [
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {mediaLibrarySource, useQuery} from '@sanity/sdk-react'
2+
import {Card, Spinner, Text} from '@sanity/ui'
3+
import {type JSX, useState} from 'react'
4+
5+
// for now, hardcoded. should be inferred from org later on
6+
const MEDIA = mediaLibrarySource('mlPGY7BEqt52')
7+
8+
export function MediaLibraryRoute(): JSX.Element {
9+
const [query] = useState('*[_type == "sanity.asset"][0...10] | order(_id desc)')
10+
const [isLoading] = useState(false)
11+
12+
const {data, isPending} = useQuery({
13+
query,
14+
source: MEDIA,
15+
})
16+
17+
return (
18+
<div style={{padding: '2rem', maxWidth: '800px', margin: '0 auto'}}>
19+
<Text size={4} weight="bold" style={{marginBottom: '2rem', color: 'white'}}>
20+
Media Library Query Demo
21+
</Text>
22+
23+
<Text size={2} style={{marginBottom: '2rem'}}>
24+
This route demonstrates querying against a Sanity media library. The MediaLibraryProvider is
25+
automatically created by SanityApp when a media library config is present. The query runs
26+
against: <code>https://api.sanity.io/v2025-03-24/media-libraries/mlPGY7BEqt52/query</code>
27+
</Text>
28+
29+
<Card padding={3} style={{marginBottom: '2rem', backgroundColor: '#1a1a1a'}}>
30+
<div style={{marginBottom: '1rem'}}>
31+
<Text size={1} style={{color: '#ccc', marginBottom: '0.5rem'}}>
32+
Current query:
33+
</Text>
34+
<code
35+
style={{
36+
display: 'block',
37+
padding: '0.5rem',
38+
backgroundColor: '#2a2a2a',
39+
borderRadius: '4px',
40+
fontFamily: 'monospace',
41+
fontSize: '0.875rem',
42+
color: '#fff',
43+
wordBreak: 'break-all',
44+
}}
45+
>
46+
{query}
47+
</code>
48+
</div>
49+
</Card>
50+
51+
<Card padding={3} style={{backgroundColor: '#1a1a1a'}}>
52+
<div style={{display: 'flex', alignItems: 'center', marginBottom: '1rem'}}>
53+
<Text size={1} weight="medium" style={{color: '#fff'}}>
54+
Query Results:
55+
</Text>
56+
{(isPending || isLoading) && <Spinner style={{marginLeft: '0.5rem'}} />}
57+
</div>
58+
59+
<pre
60+
style={{
61+
backgroundColor: '#2a2a2a',
62+
padding: '1rem',
63+
borderRadius: '4px',
64+
overflow: 'auto',
65+
maxHeight: '400px',
66+
fontSize: '0.875rem',
67+
color: '#fff',
68+
whiteSpace: 'pre-wrap',
69+
}}
70+
>
71+
{JSON.stringify(data, null, 2)}
72+
</pre>
73+
</Card>
74+
</div>
75+
)
76+
}

packages/core/src/_exports/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,11 @@ export {
7777
} from '../config/handles'
7878
export {
7979
type DatasetHandle,
80+
datasetSource,
8081
type DocumentHandle,
82+
type DocumentSource,
8183
type DocumentTypeHandle,
84+
mediaLibrarySource,
8285
type PerspectiveHandle,
8386
type ProjectHandle,
8487
type ReleasePerspective,

packages/core/src/client/clientStore.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {Subject} from 'rxjs'
33
import {beforeEach, describe, expect, it, vi} from 'vitest'
44

55
import {getAuthMethodState, getTokenState} from '../auth/authStore'
6+
import {datasetSource, mediaLibrarySource} from '../config/sanityConfig'
67
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
78
import {getClient, getClientState} from './clientStore'
89

@@ -158,4 +159,120 @@ describe('clientStore', () => {
158159
subscription.unsubscribe()
159160
})
160161
})
162+
163+
describe('source handling', () => {
164+
it('should create client when source is provided', () => {
165+
const source = datasetSource('source-project', 'source-dataset')
166+
const client = getClient(instance, {apiVersion: '2024-11-12', source})
167+
168+
expect(vi.mocked(createClient)).toHaveBeenCalledWith(
169+
expect.objectContaining({
170+
apiVersion: '2024-11-12',
171+
source: expect.objectContaining({
172+
__sanity_internal_sourceId: {
173+
projectId: 'source-project',
174+
dataset: 'source-dataset',
175+
},
176+
}),
177+
}),
178+
)
179+
// Client should be projectless - no projectId/dataset in config
180+
expect(client.config()).not.toHaveProperty('projectId')
181+
expect(client.config()).not.toHaveProperty('dataset')
182+
expect(client.config()).toEqual(
183+
expect.objectContaining({
184+
source: expect.objectContaining({
185+
__sanity_internal_sourceId: {
186+
projectId: 'source-project',
187+
dataset: 'source-dataset',
188+
},
189+
}),
190+
}),
191+
)
192+
})
193+
194+
it('should create resource when source has array sourceId and be projectless', () => {
195+
const source = mediaLibrarySource('media-lib-123')
196+
const client = getClient(instance, {apiVersion: '2024-11-12', source})
197+
198+
expect(vi.mocked(createClient)).toHaveBeenCalledWith(
199+
expect.objectContaining({
200+
'~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
201+
'apiVersion': '2024-11-12',
202+
}),
203+
)
204+
// Client should be projectless - no projectId/dataset in config
205+
expect(client.config()).not.toHaveProperty('projectId')
206+
expect(client.config()).not.toHaveProperty('dataset')
207+
expect(client.config()).toEqual(
208+
expect.objectContaining({
209+
'~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
210+
}),
211+
)
212+
})
213+
214+
it('should create projectless client when source is provided, ignoring instance config', () => {
215+
const source = datasetSource('source-project', 'source-dataset')
216+
const client = getClient(instance, {apiVersion: '2024-11-12', source})
217+
218+
// Client should be projectless - source takes precedence, instance config is ignored
219+
expect(client.config()).not.toHaveProperty('projectId')
220+
expect(client.config()).not.toHaveProperty('dataset')
221+
expect(client.config()).toEqual(
222+
expect.objectContaining({
223+
source: expect.objectContaining({
224+
__sanity_internal_sourceId: {
225+
projectId: 'source-project',
226+
dataset: 'source-dataset',
227+
},
228+
}),
229+
}),
230+
)
231+
})
232+
233+
it('should warn when both source and explicit projectId/dataset are provided', () => {
234+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
235+
const source = datasetSource('source-project', 'source-dataset')
236+
const client = getClient(instance, {
237+
apiVersion: '2024-11-12',
238+
source,
239+
projectId: 'explicit-project',
240+
dataset: 'explicit-dataset',
241+
})
242+
243+
expect(consoleSpy).toHaveBeenCalledWith(
244+
'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
245+
)
246+
// Client should still be projectless despite explicit projectId/dataset
247+
expect(client.config()).not.toHaveProperty('projectId')
248+
expect(client.config()).not.toHaveProperty('dataset')
249+
consoleSpy.mockRestore()
250+
})
251+
252+
it('should create different clients for different sources', () => {
253+
const source1 = datasetSource('project-1', 'dataset-1')
254+
const source2 = datasetSource('project-2', 'dataset-2')
255+
const source3 = mediaLibrarySource('media-lib-1')
256+
257+
const client1 = getClient(instance, {apiVersion: '2024-11-12', source: source1})
258+
const client2 = getClient(instance, {apiVersion: '2024-11-12', source: source2})
259+
const client3 = getClient(instance, {apiVersion: '2024-11-12', source: source3})
260+
261+
expect(client1).not.toBe(client2)
262+
expect(client2).not.toBe(client3)
263+
expect(client1).not.toBe(client3)
264+
expect(vi.mocked(createClient)).toHaveBeenCalledTimes(3)
265+
})
266+
267+
it('should reuse clients with identical source configurations', () => {
268+
const source = datasetSource('same-project', 'same-dataset')
269+
const options = {apiVersion: '2024-11-12', source}
270+
271+
const client1 = getClient(instance, options)
272+
const client2 = getClient(instance, options)
273+
274+
expect(client1).toBe(client2)
275+
expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1)
276+
})
277+
})
161278
})

packages/core/src/client/clientStore.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client
22
import {pick} from 'lodash-es'
33

44
import {getAuthMethodState, getTokenState} from '../auth/authStore'
5+
import {type DocumentSource, SOURCE_ID} from '../config/sanityConfig'
56
import {bindActionGlobally} from '../store/createActionBinder'
67
import {createStateSourceAction} from '../store/createStateSourceAction'
78
import {defineStore, type StoreContext} from '../store/defineStore'
@@ -39,6 +40,7 @@ const allowedKeys = Object.keys({
3940
'requestTagPrefix': null,
4041
'useProjectHostname': null,
4142
'~experimental_resource': null,
43+
'source': null,
4244
} satisfies Record<keyof ClientOptions, null>) as (keyof ClientOptions)[]
4345

4446
const DEFAULT_CLIENT_CONFIG: ClientConfig = {
@@ -90,6 +92,11 @@ export interface ClientOptions extends Pick<ClientConfig, AllowedClientConfigKey
9092
* @internal
9193
*/
9294
'~experimental_resource'?: ClientConfig['~experimental_resource']
95+
96+
/**
97+
* @internal
98+
*/
99+
'source'?: DocumentSource
93100
}
94101

95102
const clientStore = defineStore<ClientStoreState>({
@@ -156,18 +163,42 @@ export const getClient = bindActionGlobally(
156163

157164
const tokenFromState = state.get().token
158165
const {clients, authMethod} = state.get()
166+
const hasSource = !!options.source
167+
let sourceId = options.source?.[SOURCE_ID]
168+
169+
let resource
170+
if (Array.isArray(sourceId)) {
171+
resource = {type: sourceId[0], id: sourceId[1]}
172+
sourceId = undefined
173+
}
174+
159175
const projectId = options.projectId ?? instance.config.projectId
160176
const dataset = options.dataset ?? instance.config.dataset
161177
const apiHost = options.apiHost ?? instance.config.auth?.apiHost
162178

163179
const effectiveOptions: ClientOptions = {
164180
...DEFAULT_CLIENT_CONFIG,
165-
...((options.scope === 'global' || !projectId) && {useProjectHostname: false}),
181+
...((options.scope === 'global' || !projectId || hasSource) && {useProjectHostname: false}),
166182
token: authMethod === 'cookie' ? undefined : (tokenFromState ?? undefined),
167183
...options,
168184
...(projectId && {projectId}),
169185
...(dataset && {dataset}),
170186
...(apiHost && {apiHost}),
187+
...(resource && {'~experimental_resource': resource}),
188+
}
189+
190+
// When a source is provided, don't use projectId/dataset - the client should be "projectless"
191+
// The client code itself will ignore the non-source config, so we do this to prevent confusing the user.
192+
// (ref: https://github.com/sanity-io/client/blob/5c23f81f5ab93a53f5b22b39845c867988508d84/src/data/dataMethods.ts#L691)
193+
if (hasSource) {
194+
if (options.projectId || options.dataset) {
195+
// eslint-disable-next-line no-console
196+
console.warn(
197+
'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
198+
)
199+
}
200+
delete effectiveOptions.projectId
201+
delete effectiveOptions.dataset
171202
}
172203

173204
if (effectiveOptions.token === null || typeof effectiveOptions.token === 'undefined') {

packages/core/src/config/sanityConfig.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,34 @@ export interface SanityConfig extends DatasetHandle, PerspectiveHandle {
8181
enabled: boolean
8282
}
8383
}
84+
85+
export const SOURCE_ID = '__sanity_internal_sourceId'
86+
87+
/**
88+
* A document source can be used for querying.
89+
*
90+
* @beta
91+
* @see datasetSource Construct a document source for a given projectId and dataset.
92+
* @see mediaLibrarySource Construct a document source for a mediaLibraryId.
93+
*/
94+
export type DocumentSource = {
95+
[SOURCE_ID]: ['media-library', string] | {projectId: string; dataset: string}
96+
}
97+
98+
/**
99+
* Returns a document source for a projectId and dataset.
100+
*
101+
* @beta
102+
*/
103+
export function datasetSource(projectId: string, dataset: string): DocumentSource {
104+
return {[SOURCE_ID]: {projectId, dataset}}
105+
}
106+
107+
/**
108+
* Returns a document source for a Media Library.
109+
*
110+
* @beta
111+
*/
112+
export function mediaLibrarySource(id: string): DocumentSource {
113+
return {[SOURCE_ID]: ['media-library', id]}
114+
}

packages/core/src/query/queryStore.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import {
2323
} from 'rxjs'
2424

2525
import {getClientState} from '../client/clientStore'
26-
import {type DatasetHandle} from '../config/sanityConfig'
26+
import {type DatasetHandle, type DocumentSource} from '../config/sanityConfig'
2727
import {getPerspectiveState} from '../releases/getPerspectiveState'
28-
import {bindActionByDataset} from '../store/createActionBinder'
28+
import {bindActionBySource} from '../store/createActionBinder'
2929
import {type SanityInstance} from '../store/createSanityInstance'
3030
import {
3131
createStateSourceAction,
@@ -62,6 +62,7 @@ export interface QueryOptions<
6262
DatasetHandle<TDataset, TProjectId> {
6363
query: TQuery
6464
params?: Record<string, unknown>
65+
source?: DocumentSource
6566
}
6667

6768
/**
@@ -160,6 +161,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
160161
projectId,
161162
dataset,
162163
tag,
164+
source,
163165
perspective: perspectiveFromOptions,
164166
...restOptions
165167
} = parseQueryKey(group$.key)
@@ -172,6 +174,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
172174
apiVersion: QUERY_STORE_API_VERSION,
173175
projectId,
174176
dataset,
177+
source,
175178
}).observable
176179

177180
return combineLatest([lastLiveEventId$, client$, perspective$]).pipe(
@@ -290,7 +293,7 @@ export function getQueryState(
290293
): ReturnType<typeof _getQueryState> {
291294
return _getQueryState(...args)
292295
}
293-
const _getQueryState = bindActionByDataset(
296+
const _getQueryState = bindActionBySource(
294297
queryStore,
295298
createStateSourceAction({
296299
selector: ({state, instance}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
@@ -349,7 +352,7 @@ export function resolveQuery<TData>(
349352
export function resolveQuery(...args: Parameters<typeof _resolveQuery>): Promise<unknown> {
350353
return _resolveQuery(...args)
351354
}
352-
const _resolveQuery = bindActionByDataset(
355+
const _resolveQuery = bindActionBySource(
353356
queryStore,
354357
({state, instance}, {signal, ...options}: ResolveQueryOptions) => {
355358
const normalized = normalizeOptionsWithPerspective(instance, options)

0 commit comments

Comments
 (0)