Skip to content

Commit 5ef3acc

Browse files
committed
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")`.
1 parent 17927c0 commit 5ef3acc

File tree

7 files changed

+168
-13
lines changed

7 files changed

+168
-13
lines changed

apps/kitchensink-react/src/AppRoutes.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import {ProtectedRoute} from './ProtectedRoute'
1717
import {DashboardContextRoute} from './routes/DashboardContextRoute'
1818
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
1919
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
20-
import {ProjectsRoute} from './routes/ProjectsRoute'
20+
import {MediaLibraryRoute} from './routes/MediaLibraryRoute'
2121
import {PerspectivesRoute} from './routes/PerspectivesRoute'
22+
import {ProjectsRoute} from './routes/ProjectsRoute'
2223
import {ReleasesRoute} from './routes/releases/ReleasesRoute'
2324
import {UserDetailRoute} from './routes/UserDetailRoute'
2425
import {UsersRoute} from './routes/UsersRoute'
@@ -72,6 +73,10 @@ const documentCollectionRoutes = [
7273
path: 'presence',
7374
element: <PresenceRoute />,
7475
},
76+
{
77+
path: 'media-library',
78+
element: <MediaLibraryRoute />,
79+
},
7580
]
7681

7782
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
@@ -51,8 +51,11 @@ export {
5151
} from '../config/handles'
5252
export {
5353
type DatasetHandle,
54+
datasetSource,
5455
type DocumentHandle,
56+
type DocumentSource,
5557
type DocumentTypeHandle,
58+
mediaLibrarySource,
5659
type PerspectiveHandle,
5760
type ProjectHandle,
5861
type ReleasePerspective,

packages/core/src/client/clientStore.ts

Lines changed: 18 additions & 2 deletions
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,8 +163,16 @@ export const getClient = bindActionGlobally(
156163

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

163178
const effectiveOptions: ClientOptions = {
@@ -168,6 +183,7 @@ export const getClient = bindActionGlobally(
168183
...(projectId && {projectId}),
169184
...(dataset && {dataset}),
170185
...(apiHost && {apiHost}),
186+
...(resource && {'~experimental_resource': resource}),
171187
}
172188

173189
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
@@ -22,9 +22,9 @@ import {
2222
} from 'rxjs'
2323

2424
import {getClientState} from '../client/clientStore'
25-
import {type DatasetHandle} from '../config/sanityConfig'
25+
import {type DatasetHandle, type DocumentSource} from '../config/sanityConfig'
2626
import {getPerspectiveState} from '../releases/getPerspectiveState'
27-
import {bindActionByDataset} from '../store/createActionBinder'
27+
import {bindActionBySource} from '../store/createActionBinder'
2828
import {type SanityInstance} from '../store/createSanityInstance'
2929
import {
3030
createStateSourceAction,
@@ -61,6 +61,7 @@ export interface QueryOptions<
6161
DatasetHandle<TDataset, TProjectId> {
6262
query: TQuery
6363
params?: Record<string, unknown>
64+
source?: DocumentSource
6465
}
6566

6667
/**
@@ -159,6 +160,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
159160
projectId,
160161
dataset,
161162
tag,
163+
source,
162164
perspective: perspectiveFromOptions,
163165
...restOptions
164166
} = parseQueryKey(group$.key)
@@ -171,6 +173,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
171173
apiVersion: QUERY_STORE_API_VERSION,
172174
projectId,
173175
dataset,
176+
source,
174177
}).observable
175178

176179
return combineLatest([lastLiveEventId$, client$, perspective$]).pipe(
@@ -278,7 +281,7 @@ export function getQueryState(
278281
): ReturnType<typeof _getQueryState> {
279282
return _getQueryState(...args)
280283
}
281-
const _getQueryState = bindActionByDataset(
284+
const _getQueryState = bindActionBySource(
282285
queryStore,
283286
createStateSourceAction({
284287
selector: ({state, instance}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
@@ -337,7 +340,7 @@ export function resolveQuery<TData>(
337340
export function resolveQuery(...args: Parameters<typeof _resolveQuery>): Promise<unknown> {
338341
return _resolveQuery(...args)
339342
}
340-
const _resolveQuery = bindActionByDataset(
343+
const _resolveQuery = bindActionBySource(
341344
queryStore,
342345
({state, instance}, {signal, ...options}: ResolveQueryOptions) => {
343346
const normalized = normalizeOptionsWithPerspective(instance, options)

packages/core/src/store/createActionBinder.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {type SanityConfig} from '../config/sanityConfig'
1+
import {type DocumentSource, type SanityConfig, SOURCE_ID} from '../config/sanityConfig'
22
import {type SanityInstance} from './createSanityInstance'
33
import {createStoreInstance, type StoreInstance} from './createStoreInstance'
44
import {type StoreState} from './createStoreState'
@@ -43,7 +43,9 @@ export type BoundStoreAction<_TState, TParams extends unknown[], TReturn> = (
4343
* )
4444
* ```
4545
*/
46-
export function createActionBinder(keyFn: (config: SanityConfig) => string) {
46+
export function createActionBinder<TKeyParams extends unknown[]>(
47+
keyFn: (config: SanityConfig, ...params: TKeyParams) => string,
48+
) {
4749
const instanceRegistry = new Map<string, Set<string>>()
4850
const storeRegistry = new Map<string, StoreInstance<unknown>>()
4951

@@ -54,12 +56,12 @@ export function createActionBinder(keyFn: (config: SanityConfig) => string) {
5456
* @param action - The action to bind
5557
* @returns A function that executes the action with a Sanity instance
5658
*/
57-
return function bindAction<TState, TParams extends unknown[], TReturn>(
59+
return function bindAction<TState, TParams extends TKeyParams, TReturn>(
5860
storeDefinition: StoreDefinition<TState>,
5961
action: StoreAction<TState, TParams, TReturn>,
6062
): BoundStoreAction<TState, TParams, TReturn> {
6163
return function boundAction(instance: SanityInstance, ...params: TParams) {
62-
const keySuffix = keyFn(instance.config)
64+
const keySuffix = keyFn(instance.config, ...params)
6365
const compositeKey = storeDefinition.name + (keySuffix ? `:${keySuffix}` : '')
6466

6567
// Get or create instance set for this composite key
@@ -128,13 +130,32 @@ export function createActionBinder(keyFn: (config: SanityConfig) => string) {
128130
* fetchDocument(sanityInstance, 'doc123')
129131
* ```
130132
*/
131-
export const bindActionByDataset = createActionBinder(({projectId, dataset}) => {
133+
export const bindActionByDataset = createActionBinder<unknown[]>(({projectId, dataset}) => {
132134
if (!projectId || !dataset) {
133135
throw new Error('This API requires a project ID and dataset configured.')
134136
}
135137
return `${projectId}.${dataset}`
136138
})
137139

140+
/**
141+
* Binds an action to a store that's scoped to a specific document source.
142+
**/
143+
export const bindActionBySource = createActionBinder<[{source?: DocumentSource}, ...unknown[]]>(
144+
({projectId, dataset}, {source}) => {
145+
if (source) {
146+
const id = source[SOURCE_ID]
147+
if (!id) throw new Error('Invalid source (missing ID information)')
148+
if (Array.isArray(id)) return id.join(':')
149+
return `${id.projectId}.${id.dataset}`
150+
}
151+
152+
if (!projectId || !dataset) {
153+
throw new Error('This API requires a project ID and dataset configured.')
154+
}
155+
return `${projectId}.${dataset}`
156+
},
157+
)
158+
138159
/**
139160
* Binds an action to a global store that's shared across all Sanity instances
140161
*
@@ -173,4 +194,4 @@ export const bindActionByDataset = createActionBinder(({projectId, dataset}) =>
173194
* getCurrentUser(sanityInstance)
174195
* ```
175196
*/
176-
export const bindActionGlobally = createActionBinder(() => 'global')
197+
export const bindActionGlobally = createActionBinder<unknown[]>(() => 'global')

0 commit comments

Comments
 (0)