@sanity/sdk 3.8.0 → 3.9.0-next.20261007114705

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 (27) hide show
  1. package/dist/_exports/_internal.d.ts +8 -2
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +8 -8
  4. package/dist/_exports/agent.js +1 -1
  5. package/dist/_exports/dashboard.d.ts +2 -2
  6. package/dist/_exports/dashboard.js +2 -2
  7. package/dist/{applications-CaitEfgp.d.ts → applications-BioeDdGe.d.ts} +46 -11
  8. package/dist/{applications-CaitEfgp.d.ts.map → applications-BioeDdGe.d.ts.map} +1 -1
  9. package/dist/{authStore-DRX3qU3e.js → authStore-CwZPW3jK.js} +10 -2
  10. package/dist/{authStore-DRX3qU3e.js.map → authStore-CwZPW3jK.js.map} +1 -1
  11. package/dist/{bus-BjkKneLg.d.ts → bus-BULkpPgi.d.ts} +2 -2
  12. package/dist/{bus-BjkKneLg.d.ts.map → bus-BULkpPgi.d.ts.map} +1 -1
  13. package/dist/{clientStore-mFP_nOqf.js → clientStore-iv9ooGew.js} +2 -2
  14. package/dist/{clientStore-mFP_nOqf.js.map → clientStore-iv9ooGew.js.map} +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +4 -4
  17. package/dist/{topicStore-D5RKaWQk.js → topicStore-CjKrnfeL.js} +2 -2
  18. package/dist/{topicStore-D5RKaWQk.js.map → topicStore-CjKrnfeL.js.map} +1 -1
  19. package/dist/{version-asrkIujR.js → version-D68H4Kcd.js} +3 -3
  20. package/dist/{version-asrkIujR.js.map → version-D68H4Kcd.js.map} +1 -1
  21. package/package.json +2 -2
  22. package/src/_exports/_internal.ts +1 -0
  23. package/src/auth/utils.test.ts +25 -0
  24. package/src/auth/utils.ts +10 -1
  25. package/src/config/__tests__/handles.test-d.ts +24 -0
  26. package/src/typegen/resolve.test-d.ts +105 -0
  27. package/src/typegen/resolve.ts +61 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "3.8.0",
3
+ "version": "3.9.0-next.20261007114705",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -73,7 +73,7 @@
73
73
  "@types/node": "^24.19.1",
74
74
  "@vitest/coverage-v8": "^5.0.3",
75
75
  "eslint": "^10.12.0",
76
- "oxfmt": "^0.71.0",
76
+ "oxfmt": "^0.72.0",
77
77
  "rolldown": "^1.2.12",
78
78
  "typescript": "^6.0.3",
79
79
  "vite": "^8.3.2",
@@ -4,6 +4,7 @@ export {
4
4
  type ApiErrorBody,
5
5
  getClientErrorApiBody,
6
6
  getClientErrorApiDescription,
7
+ getClientErrorApiProjectId,
7
8
  getClientErrorApiType,
8
9
  isProjectUserNotFoundClientError,
9
10
  } from '../auth/utils'
@@ -1,3 +1,4 @@
1
+ import {ClientError} from '@sanity/client'
1
2
  import {EMPTY, NEVER} from 'rxjs'
2
3
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
4
 
@@ -5,6 +6,7 @@ import {AUTH_CODE_PARAM, DEFAULT_BASE} from './authConstants'
5
6
  import {
6
7
  getAuthCode,
7
8
  getCleanedUrl,
9
+ getClientErrorApiProjectId,
8
10
  getDefaultLocation,
9
11
  getDefaultStorage,
10
12
  getStorageEvents,
@@ -264,3 +266,26 @@ describe('getCleanedUrl', () => {
264
266
  expect(cleaned).toBe('http://example.com/page#section')
265
267
  })
266
268
  })
269
+
270
+ describe('getClientErrorApiProjectId', () => {
271
+ const clientError = (body: unknown) =>
272
+ new ClientError({
273
+ statusCode: 401,
274
+ headers: {},
275
+ body,
276
+ url: 'https://exx11uqh.api.sanity.work/v2025-05-06/data/query/production',
277
+ method: 'GET',
278
+ } as ConstructorParameters<typeof ClientError>[0])
279
+
280
+ it('reads the project from the error body', () => {
281
+ expect(getClientErrorApiProjectId(clientError({error: {projectID: 'exx11uqh'}}))).toBe(
282
+ 'exx11uqh',
283
+ )
284
+ })
285
+
286
+ it('ignores a missing, empty or non-string project', () => {
287
+ expect(getClientErrorApiProjectId(clientError({error: {}}))).toBeUndefined()
288
+ expect(getClientErrorApiProjectId(clientError({error: {projectID: 42}}))).toBeUndefined()
289
+ expect(getClientErrorApiProjectId(clientError({error: {projectID: ''}}))).toBeUndefined()
290
+ })
291
+ })
package/src/auth/utils.ts CHANGED
@@ -175,7 +175,7 @@ export function getCleanedUrl(locationUrl: string): string {
175
175
 
176
176
  /** @internal */
177
177
  export type ApiErrorBody = {
178
- error?: {type?: string; description?: string}
178
+ error?: {type?: string; description?: string; projectID?: string}
179
179
  type?: string
180
180
  description?: string
181
181
  message?: string
@@ -199,6 +199,15 @@ export function getClientErrorApiDescription(error: ClientError): string | undef
199
199
  return body?.error?.description ?? body?.description
200
200
  }
201
201
 
202
+ /**
203
+ * Returns the project an API error refers to, if available.
204
+ * @internal
205
+ */
206
+ export function getClientErrorApiProjectId(error: ClientError): string | undefined {
207
+ const projectId = getClientErrorApiBody(error)?.error?.projectID
208
+ return typeof projectId === 'string' && projectId ? projectId : undefined
209
+ }
210
+
202
211
  /** @internal True if the error represents a projectUserNotFoundError. */
203
212
  export function isProjectUserNotFoundClientError(error: ClientError): boolean {
204
213
  return getClientErrorApiType(error) === 'projectUserNotFoundError'
@@ -0,0 +1,24 @@
1
+ import {expectTypeOf, test} from 'vitest'
2
+
3
+ import {createDocumentHandle} from '../handles'
4
+
5
+ // `createDocumentHandle` exists to capture the literal types of its input. Its runtime
6
+ // behavior, returning the input unchanged, is covered in `handles.test.ts` and is the
7
+ // uninteresting half: a signature change that dropped `TDataset` or `TProjectId` would
8
+ // pass every runtime test while silently removing inference for every consumer.
9
+ //
10
+ // Typegen looks results up by a `projectId.dataset` key, so a handle carrying `string`
11
+ // instead of a literal matches nothing.
12
+
13
+ test('createDocumentHandle preserves the literal types of its input', () => {
14
+ const handle = createDocumentHandle({
15
+ projectId: 'ppsg7ml5',
16
+ dataset: 'test',
17
+ documentId: 'some-id',
18
+ documentType: 'author',
19
+ })
20
+
21
+ expectTypeOf(handle.projectId).toEqualTypeOf<'ppsg7ml5' | undefined>()
22
+ expectTypeOf(handle.dataset).toEqualTypeOf<'test' | undefined>()
23
+ expectTypeOf(handle.documentType).toEqualTypeOf<'author'>()
24
+ })
@@ -0,0 +1,105 @@
1
+ import {expectTypeOf, test} from 'vitest'
2
+
3
+ import {type ActionsResult} from '../document/applyDocumentActions'
4
+ import {
5
+ type ResolveDocument,
6
+ type ResolveProjectionResult,
7
+ type ResolveQueryResult,
8
+ } from './resolve'
9
+
10
+ // Stands in for what a multi-resource `sanity typegen` run emits. Two resources whose
11
+ // schemas disagree, plus an object type that must not resolve as a document.
12
+ type TestPost = {
13
+ _id: string
14
+ _type: 'post'
15
+ _createdAt: string
16
+ _updatedAt: string
17
+ _rev: string
18
+ title: string
19
+ }
20
+ interface ProductionPost {
21
+ _id: string
22
+ _type: 'post'
23
+ _createdAt: string
24
+ _updatedAt: string
25
+ _rev: string
26
+ title: number
27
+ }
28
+ type Slug = {_type: 'slug'; current: string}
29
+
30
+ type TestPostsResult = {_id: string; title: string}[]
31
+ type ProductionPostsResult = {_id: string; title: number}[]
32
+ type TestTitleProjection = {title: string}
33
+
34
+ // Registered the way generated files do: as globals, which the interfaces `@sanity/client`
35
+ // exports extend. Every file in this package's type-check program sees these registrations, not
36
+ // only this test, so their keys must stay unique across the package's tests.
37
+ declare global {
38
+ interface SanitySchemasByResource {
39
+ 'resolve1.test': TestPost | Slug
40
+ 'resolve2.production': ProductionPost
41
+ }
42
+ interface SanityQueriesByResource {
43
+ 'resolve1.test': {'*[_type == "post"]': TestPostsResult}
44
+ 'resolve2.production': {'*[_type == "post"]': ProductionPostsResult}
45
+ }
46
+ interface SanityProjectionsByResource {
47
+ 'resolve1.test': {post: {'{title}': TestTitleProjection}}
48
+ }
49
+ }
50
+
51
+ test('registered resources resolve documents declared as aliases or interfaces', () => {
52
+ expectTypeOf<ResolveDocument<'post', 'resolve1.test'>>().toEqualTypeOf<TestPost>()
53
+ expectTypeOf<ResolveDocument<'post', 'resolve2.production'>>().toEqualTypeOf<ProductionPost>()
54
+ })
55
+
56
+ test('interface registrations remain usable in document action results', () => {
57
+ type Result = ActionsResult<ResolveDocument<'post', 'resolve2.production'>>
58
+ expectTypeOf<NonNullable<Result['documents'][string]>>().toEqualTypeOf<ProductionPost>()
59
+ })
60
+
61
+ test('a document type absent from a registered resource is never', () => {
62
+ // Not the legacy union. Falling through would describe the wrong dataset while looking
63
+ // precise.
64
+ expectTypeOf<ResolveDocument<'movie', 'resolve1.test'>>().toEqualTypeOf<never>()
65
+ })
66
+
67
+ test('an object type in the schema union is not a document', () => {
68
+ expectTypeOf<ResolveDocument<'slug', 'resolve1.test'>>().toEqualTypeOf<never>()
69
+ })
70
+
71
+ test('an unconstrained document type yields the resource documents only', () => {
72
+ expectTypeOf<ResolveDocument<string, 'resolve1.test'>>().toEqualTypeOf<TestPost>()
73
+ })
74
+
75
+ test('an unregistered resource falls back to the legacy lookup', () => {
76
+ // The legacy path answers with the base document shape when nothing is registered, which
77
+ // is what keeps saved experimental output working.
78
+ expectTypeOf<ResolveDocument<string, 'unregistered.dataset'>['_id']>().toEqualTypeOf<string>()
79
+ expectTypeOf<ResolveDocument<'post', 'unregistered.dataset'>>().toEqualTypeOf<never>()
80
+ })
81
+
82
+ test('the same query text resolves to a different type per resource', () => {
83
+ expectTypeOf<
84
+ ResolveQueryResult<'*[_type == "post"]', 'resolve1.test'>
85
+ >().toEqualTypeOf<TestPostsResult>()
86
+ expectTypeOf<
87
+ ResolveQueryResult<'*[_type == "post"]', 'resolve2.production'>
88
+ >().toEqualTypeOf<ProductionPostsResult>()
89
+ })
90
+
91
+ test('an unregistered query on a registered resource is never', () => {
92
+ expectTypeOf<ResolveQueryResult<'*[_type == "movie"]', 'resolve1.test'>>().toEqualTypeOf<never>()
93
+ })
94
+
95
+ test('a projection resolves by resource and document type', () => {
96
+ expectTypeOf<
97
+ ResolveProjectionResult<'{title}', 'post', 'resolve1.test'>
98
+ >().toEqualTypeOf<TestTitleProjection>()
99
+ })
100
+
101
+ test('a projection on a document type with no registrations is never', () => {
102
+ expectTypeOf<
103
+ ResolveProjectionResult<'{title}', 'movie', 'resolve1.test'>
104
+ >().toEqualTypeOf<never>()
105
+ })
@@ -1,32 +1,79 @@
1
1
  import './groqCompat'
2
2
 
3
+ import {
4
+ type SanityProjectionsByResource,
5
+ type SanityQueriesByResource,
6
+ type SanitySchemasByResource,
7
+ } from '@sanity/client'
3
8
  import {type SanityDocument, type SanityProjectionResult, type SanityQueryResult} from 'groq'
4
9
 
5
10
  /**
6
- * Resolves a document using declarations from experimental Typegen.
7
- * Preserves the legacy lookup behavior while the SDK uses released `groq`.
11
+ * Indexes `T` by `K`, or `never` when `K` is not a key of `T`.
12
+ *
13
+ * Deliberately exact. The legacy `groq` lookup falls back to the union of every registered
14
+ * value on a miss, which lets a query resolve to another dataset's result type.
15
+ */
16
+ type At<T, K> = K extends keyof T ? T[K] : never
17
+
18
+ /**
19
+ * Fields Sanity adds to every document. A resource's schema union also carries object types
20
+ * such as `slug`, which lack these, so this separates documents from the rest.
21
+ * No index signature is required, so both type aliases and interfaces are supported.
22
+ */
23
+ interface DocumentFields {
24
+ _id: string
25
+ _type: string
26
+ _createdAt: string
27
+ _updatedAt: string
28
+ _rev: string
29
+ }
30
+
31
+ /**
32
+ * Copies document fields into a type alias so interface registrations satisfy the
33
+ * record constraints used by document actions without adding an index signature.
34
+ */
35
+ type DocumentRecord<T> = {[K in keyof T]: T[K]}
36
+
37
+ /**
38
+ * Resolves a document type for one resource.
39
+ *
40
+ * A resource registered in `SanitySchemasByResource` is answered from there and only there:
41
+ * a document type absent from a registered resource is `never`, never the legacy union,
42
+ * because an unrelated union looks precise while describing the wrong dataset. A resource
43
+ * with no registration falls back to the experimental Typegen declarations, which is what
44
+ * keeps saved generated files working.
8
45
  *
9
46
  * @beta
10
47
  */
11
48
  export type ResolveDocument<
12
49
  TDocumentType extends string = string,
13
50
  TSchemaId extends string = string,
14
- > = SanityDocument<TDocumentType, TSchemaId>
51
+ > = TSchemaId extends keyof SanitySchemasByResource
52
+ ? DocumentRecord<
53
+ Extract<Extract<SanitySchemasByResource[TSchemaId], DocumentFields>, {_type: TDocumentType}>
54
+ >
55
+ : SanityDocument<TDocumentType, TSchemaId>
15
56
 
16
57
  /**
17
- * Resolves a query using declarations from experimental Typegen.
18
- * For types generated by current Typegen, pass the result type to the hook explicitly.
58
+ * Resolves a query result for one resource, selected by the exact query text.
59
+ *
60
+ * Falls back to the experimental Typegen declarations for an unregistered resource. See
61
+ * {@link ResolveDocument} for why a registered resource does not fall through.
19
62
  *
20
63
  * @beta
21
64
  */
22
65
  export type ResolveQueryResult<
23
66
  TQuery extends string = string,
24
67
  TSchemaId extends string = string,
25
- > = SanityQueryResult<TQuery, TSchemaId>
68
+ > = TSchemaId extends keyof SanityQueriesByResource
69
+ ? At<SanityQueriesByResource[TSchemaId], TQuery>
70
+ : SanityQueryResult<TQuery, TSchemaId>
26
71
 
27
72
  /**
28
- * Resolves a projection using declarations from experimental Typegen.
29
- * Selects the result by projection string, document type, and schema ID.
73
+ * Resolves a projection result for one resource and document type.
74
+ *
75
+ * A projection runs against the document its handle names, so the same projection text
76
+ * resolves differently per document type as well as per resource.
30
77
  *
31
78
  * @beta
32
79
  */
@@ -34,4 +81,9 @@ export type ResolveProjectionResult<
34
81
  TProjection extends string = string,
35
82
  TDocumentType extends string = string,
36
83
  TSchemaId extends string = string,
37
- > = SanityProjectionResult<TProjection, TDocumentType, TSchemaId>
84
+ > = TSchemaId extends keyof SanityProjectionsByResource
85
+ ? // `Extract<..., object>` keeps this assignable to the `object` bound that
86
+ // `ProjectionValuePending` places on a projection result. A projection always selects
87
+ // fields into an object, so nothing is lost.
88
+ Extract<At<At<SanityProjectionsByResource[TSchemaId], TDocumentType>, TProjection>, object>
89
+ : SanityProjectionResult<TProjection, TDocumentType, TSchemaId>