Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/kitchensink-react/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {ProtectedRoute} from './ProtectedRoute'
import {DashboardContextRoute} from './routes/DashboardContextRoute'
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
import {MediaLibraryRoute} from './routes/MediaLibraryRoute'
import {PerspectivesRoute} from './routes/PerspectivesRoute'
import {ProjectsRoute} from './routes/ProjectsRoute'
import {ReleasesRoute} from './routes/releases/ReleasesRoute'
Expand Down Expand Up @@ -72,6 +73,10 @@ const documentCollectionRoutes = [
path: 'presence',
element: <PresenceRoute />,
},
{
path: 'media-library',
element: <MediaLibraryRoute />,
},
]

const dashboardInteractionRoutes = [
Expand Down
76 changes: 76 additions & 0 deletions apps/kitchensink-react/src/routes/MediaLibraryRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {mediaLibrarySource, useQuery} from '@sanity/sdk-react'
import {Card, Spinner, Text} from '@sanity/ui'
import {type JSX, useState} from 'react'

// for now, hardcoded. should be inferred from org later on
const MEDIA = mediaLibrarySource('mlPGY7BEqt52')

export function MediaLibraryRoute(): JSX.Element {
const [query] = useState('*[_type == "sanity.asset"][0...10] | order(_id desc)')
const [isLoading] = useState(false)

const {data, isPending} = useQuery({
query,
source: MEDIA,
})

return (
<div style={{padding: '2rem', maxWidth: '800px', margin: '0 auto'}}>
<Text size={4} weight="bold" style={{marginBottom: '2rem', color: 'white'}}>
Media Library Query Demo
</Text>

<Text size={2} style={{marginBottom: '2rem'}}>
This route demonstrates querying against a Sanity media library. The MediaLibraryProvider is
automatically created by SanityApp when a media library config is present. The query runs
against: <code>https://api.sanity.io/v2025-03-24/media-libraries/mlPGY7BEqt52/query</code>
</Text>

<Card padding={3} style={{marginBottom: '2rem', backgroundColor: '#1a1a1a'}}>
<div style={{marginBottom: '1rem'}}>
<Text size={1} style={{color: '#ccc', marginBottom: '0.5rem'}}>
Current query:
</Text>
<code
style={{
display: 'block',
padding: '0.5rem',
backgroundColor: '#2a2a2a',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '0.875rem',
color: '#fff',
wordBreak: 'break-all',
}}
>
{query}
</code>
</div>
</Card>

<Card padding={3} style={{backgroundColor: '#1a1a1a'}}>
<div style={{display: 'flex', alignItems: 'center', marginBottom: '1rem'}}>
<Text size={1} weight="medium" style={{color: '#fff'}}>
Query Results:
</Text>
{(isPending || isLoading) && <Spinner style={{marginLeft: '0.5rem'}} />}
</div>

<pre
style={{
backgroundColor: '#2a2a2a',
padding: '1rem',
borderRadius: '4px',
overflow: 'auto',
maxHeight: '400px',
fontSize: '0.875rem',
color: '#fff',
whiteSpace: 'pre-wrap',
}}
>
{JSON.stringify(data, null, 2)}
</pre>
</Card>
</div>
)
}
3 changes: 3 additions & 0 deletions packages/core/src/_exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ export {
} from '../config/handles'
export {
type DatasetHandle,
datasetSource,
type DocumentHandle,
type DocumentSource,
type DocumentTypeHandle,
mediaLibrarySource,
type PerspectiveHandle,
type ProjectHandle,
type ReleasePerspective,
Expand Down
117 changes: 117 additions & 0 deletions packages/core/src/client/clientStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Subject} from 'rxjs'
import {beforeEach, describe, expect, it, vi} from 'vitest'

import {getAuthMethodState, getTokenState} from '../auth/authStore'
import {datasetSource, mediaLibrarySource} from '../config/sanityConfig'
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
import {getClient, getClientState} from './clientStore'

Expand Down Expand Up @@ -158,4 +159,120 @@ describe('clientStore', () => {
subscription.unsubscribe()
})
})

describe('source handling', () => {
it('should create client when source is provided', () => {
const source = datasetSource('source-project', 'source-dataset')
const client = getClient(instance, {apiVersion: '2024-11-12', source})

expect(vi.mocked(createClient)).toHaveBeenCalledWith(
expect.objectContaining({
apiVersion: '2024-11-12',
source: expect.objectContaining({
__sanity_internal_sourceId: {
projectId: 'source-project',
dataset: 'source-dataset',
},
}),
}),
)
// Client should be projectless - no projectId/dataset in config
expect(client.config()).not.toHaveProperty('projectId')
expect(client.config()).not.toHaveProperty('dataset')
expect(client.config()).toEqual(
expect.objectContaining({
source: expect.objectContaining({
__sanity_internal_sourceId: {
projectId: 'source-project',
dataset: 'source-dataset',
},
}),
}),
)
})

it('should create resource when source has array sourceId and be projectless', () => {
const source = mediaLibrarySource('media-lib-123')
const client = getClient(instance, {apiVersion: '2024-11-12', source})

expect(vi.mocked(createClient)).toHaveBeenCalledWith(
expect.objectContaining({
'~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
'apiVersion': '2024-11-12',
}),
)
// Client should be projectless - no projectId/dataset in config
expect(client.config()).not.toHaveProperty('projectId')
expect(client.config()).not.toHaveProperty('dataset')
expect(client.config()).toEqual(
expect.objectContaining({
'~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
}),
)
})

it('should create projectless client when source is provided, ignoring instance config', () => {
const source = datasetSource('source-project', 'source-dataset')
const client = getClient(instance, {apiVersion: '2024-11-12', source})

// Client should be projectless - source takes precedence, instance config is ignored
expect(client.config()).not.toHaveProperty('projectId')
expect(client.config()).not.toHaveProperty('dataset')
expect(client.config()).toEqual(
expect.objectContaining({
source: expect.objectContaining({
__sanity_internal_sourceId: {
projectId: 'source-project',
dataset: 'source-dataset',
},
}),
}),
)
})

it('should warn when both source and explicit projectId/dataset are provided', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const source = datasetSource('source-project', 'source-dataset')
const client = getClient(instance, {
apiVersion: '2024-11-12',
source,
projectId: 'explicit-project',
dataset: 'explicit-dataset',
})

expect(consoleSpy).toHaveBeenCalledWith(
'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
)
// Client should still be projectless despite explicit projectId/dataset
expect(client.config()).not.toHaveProperty('projectId')
expect(client.config()).not.toHaveProperty('dataset')
consoleSpy.mockRestore()
})

it('should create different clients for different sources', () => {
const source1 = datasetSource('project-1', 'dataset-1')
const source2 = datasetSource('project-2', 'dataset-2')
const source3 = mediaLibrarySource('media-lib-1')

const client1 = getClient(instance, {apiVersion: '2024-11-12', source: source1})
const client2 = getClient(instance, {apiVersion: '2024-11-12', source: source2})
const client3 = getClient(instance, {apiVersion: '2024-11-12', source: source3})

expect(client1).not.toBe(client2)
expect(client2).not.toBe(client3)
expect(client1).not.toBe(client3)
expect(vi.mocked(createClient)).toHaveBeenCalledTimes(3)
})

it('should reuse clients with identical source configurations', () => {
const source = datasetSource('same-project', 'same-dataset')
const options = {apiVersion: '2024-11-12', source}

const client1 = getClient(instance, options)
const client2 = getClient(instance, options)

expect(client1).toBe(client2)
expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1)
})
})
})
33 changes: 32 additions & 1 deletion packages/core/src/client/clientStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client
import {pick} from 'lodash-es'

import {getAuthMethodState, getTokenState} from '../auth/authStore'
import {type DocumentSource, SOURCE_ID} from '../config/sanityConfig'
import {bindActionGlobally} from '../store/createActionBinder'
import {createStateSourceAction} from '../store/createStateSourceAction'
import {defineStore, type StoreContext} from '../store/defineStore'
Expand Down Expand Up @@ -39,6 +40,7 @@ const allowedKeys = Object.keys({
'requestTagPrefix': null,
'useProjectHostname': null,
'~experimental_resource': null,
'source': null,
} satisfies Record<keyof ClientOptions, null>) as (keyof ClientOptions)[]

const DEFAULT_CLIENT_CONFIG: ClientConfig = {
Expand Down Expand Up @@ -90,6 +92,11 @@ export interface ClientOptions extends Pick<ClientConfig, AllowedClientConfigKey
* @internal
*/
'~experimental_resource'?: ClientConfig['~experimental_resource']

/**
* @internal
*/
'source'?: DocumentSource
}

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

const tokenFromState = state.get().token
const {clients, authMethod} = state.get()
const hasSource = !!options.source
let sourceId = options.source?.[SOURCE_ID]

let resource
if (Array.isArray(sourceId)) {
resource = {type: sourceId[0], id: sourceId[1]}
sourceId = undefined
}

const projectId = options.projectId ?? instance.config.projectId
const dataset = options.dataset ?? instance.config.dataset
const apiHost = options.apiHost ?? instance.config.auth?.apiHost

const effectiveOptions: ClientOptions = {
...DEFAULT_CLIENT_CONFIG,
...((options.scope === 'global' || !projectId) && {useProjectHostname: false}),
...((options.scope === 'global' || !projectId || hasSource) && {useProjectHostname: false}),
token: authMethod === 'cookie' ? undefined : (tokenFromState ?? undefined),
...options,
...(projectId && {projectId}),
...(dataset && {dataset}),
...(apiHost && {apiHost}),
...(resource && {'~experimental_resource': resource}),
}

// When a source is provided, don't use projectId/dataset - the client should be "projectless"
// The client code itself will ignore the non-source config, so we do this to prevent confusing the user.
// (ref: https://github.com/sanity-io/client/blob/5c23f81f5ab93a53f5b22b39845c867988508d84/src/data/dataMethods.ts#L691)
if (hasSource) {
if (options.projectId || options.dataset) {
// eslint-disable-next-line no-console
console.warn(
'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
)
}
delete effectiveOptions.projectId
delete effectiveOptions.dataset
}

if (effectiveOptions.token === null || typeof effectiveOptions.token === 'undefined') {
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/config/sanityConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,34 @@ export interface SanityConfig extends DatasetHandle, PerspectiveHandle {
enabled: boolean
}
}

export const SOURCE_ID = '__sanity_internal_sourceId'

/**
* A document source can be used for querying.
*
* @beta
* @see datasetSource Construct a document source for a given projectId and dataset.
* @see mediaLibrarySource Construct a document source for a mediaLibraryId.
*/
export type DocumentSource = {
[SOURCE_ID]: ['media-library', string] | {projectId: string; dataset: string}
}

/**
* Returns a document source for a projectId and dataset.
*
* @beta
*/
export function datasetSource(projectId: string, dataset: string): DocumentSource {
return {[SOURCE_ID]: {projectId, dataset}}
}

/**
* Returns a document source for a Media Library.
*
* @beta
*/
export function mediaLibrarySource(id: string): DocumentSource {
return {[SOURCE_ID]: ['media-library', id]}
}
11 changes: 7 additions & 4 deletions packages/core/src/query/queryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import {
} from 'rxjs'

import {getClientState} from '../client/clientStore'
import {type DatasetHandle} from '../config/sanityConfig'
import {type DatasetHandle, type DocumentSource} from '../config/sanityConfig'
import {getPerspectiveState} from '../releases/getPerspectiveState'
import {bindActionByDataset} from '../store/createActionBinder'
import {bindActionBySource} from '../store/createActionBinder'
import {type SanityInstance} from '../store/createSanityInstance'
import {
createStateSourceAction,
Expand Down Expand Up @@ -62,6 +62,7 @@ export interface QueryOptions<
DatasetHandle<TDataset, TProjectId> {
query: TQuery
params?: Record<string, unknown>
source?: DocumentSource
}

/**
Expand Down Expand Up @@ -160,6 +161,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
projectId,
dataset,
tag,
source,
perspective: perspectiveFromOptions,
...restOptions
} = parseQueryKey(group$.key)
Expand All @@ -172,6 +174,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
apiVersion: QUERY_STORE_API_VERSION,
projectId,
dataset,
source,
}).observable

return combineLatest([lastLiveEventId$, client$, perspective$]).pipe(
Expand Down Expand Up @@ -290,7 +293,7 @@ export function getQueryState(
): ReturnType<typeof _getQueryState> {
return _getQueryState(...args)
}
const _getQueryState = bindActionByDataset(
const _getQueryState = bindActionBySource(
queryStore,
createStateSourceAction({
selector: ({state, instance}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
Expand Down Expand Up @@ -349,7 +352,7 @@ export function resolveQuery<TData>(
export function resolveQuery(...args: Parameters<typeof _resolveQuery>): Promise<unknown> {
return _resolveQuery(...args)
}
const _resolveQuery = bindActionByDataset(
const _resolveQuery = bindActionBySource(
queryStore,
({state, instance}, {signal, ...options}: ResolveQueryOptions) => {
const normalized = normalizeOptionsWithPerspective(instance, options)
Expand Down
Loading
Loading