@sanity/sdk 2.7.0 → 2.9.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 (92) hide show
  1. package/dist/_chunks-dts/utils.d.ts +2396 -0
  2. package/dist/_chunks-es/_internal.js +129 -0
  3. package/dist/_chunks-es/_internal.js.map +1 -0
  4. package/dist/_chunks-es/createGroqSearchFilter.js +1460 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  6. package/dist/_chunks-es/telemetryManager.js +87 -0
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  8. package/dist/_chunks-es/version.js +7 -0
  9. package/dist/_chunks-es/version.js.map +1 -0
  10. package/dist/_exports/_internal.d.ts +64 -0
  11. package/dist/_exports/_internal.js +20 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +2 -2343
  14. package/dist/index.js +383 -1777
  15. package/dist/index.js.map +1 -1
  16. package/package.json +11 -4
  17. package/src/_exports/_internal.ts +14 -0
  18. package/src/_exports/index.ts +10 -1
  19. package/src/auth/authStore.test.ts +150 -1
  20. package/src/auth/authStore.ts +11 -11
  21. package/src/auth/dashboardAuth.ts +2 -2
  22. package/src/auth/handleAuthCallback.ts +9 -3
  23. package/src/auth/logout.test.ts +1 -1
  24. package/src/auth/logout.ts +1 -1
  25. package/src/auth/refreshStampedToken.test.ts +118 -1
  26. package/src/auth/refreshStampedToken.ts +3 -2
  27. package/src/auth/standaloneAuth.ts +9 -3
  28. package/src/auth/studioAuth.ts +34 -7
  29. package/src/auth/studioModeAuth.ts +2 -1
  30. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +10 -2
  31. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +5 -1
  32. package/src/auth/subscribeToStorageEventsAndSetToken.ts +2 -2
  33. package/src/auth/utils.ts +33 -0
  34. package/src/client/clientStore.test.ts +14 -0
  35. package/src/client/clientStore.ts +2 -1
  36. package/src/comlink/node/getNodeState.ts +2 -1
  37. package/src/config/sanityConfig.ts +6 -0
  38. package/src/document/actions.ts +18 -11
  39. package/src/document/applyDocumentActions.test.ts +7 -6
  40. package/src/document/applyDocumentActions.ts +10 -4
  41. package/src/document/documentStore.test.ts +536 -188
  42. package/src/document/documentStore.ts +142 -76
  43. package/src/document/events.ts +7 -2
  44. package/src/document/permissions.test.ts +18 -16
  45. package/src/document/permissions.ts +35 -11
  46. package/src/document/processActions.test.ts +359 -32
  47. package/src/document/processActions.ts +104 -76
  48. package/src/document/reducers.test.ts +117 -29
  49. package/src/document/reducers.ts +43 -36
  50. package/src/document/sharedListener.ts +16 -6
  51. package/src/document/util.ts +14 -0
  52. package/src/favorites/favorites.test.ts +9 -2
  53. package/src/presence/bifurTransport.ts +6 -1
  54. package/src/preview/getPreviewState.test.ts +115 -98
  55. package/src/preview/getPreviewState.ts +38 -60
  56. package/src/preview/previewProjectionUtils.test.ts +179 -0
  57. package/src/preview/previewProjectionUtils.ts +93 -0
  58. package/src/preview/resolvePreview.test.ts +42 -25
  59. package/src/preview/resolvePreview.ts +29 -10
  60. package/src/preview/{previewStore.ts → types.ts} +8 -17
  61. package/src/projection/getProjectionState.test.ts +16 -16
  62. package/src/projection/getProjectionState.ts +2 -1
  63. package/src/projection/projectionQuery.ts +2 -3
  64. package/src/projection/types.ts +1 -1
  65. package/src/query/queryStore.ts +2 -1
  66. package/src/releases/getPerspectiveState.ts +7 -6
  67. package/src/releases/releasesStore.test.ts +20 -5
  68. package/src/releases/releasesStore.ts +20 -8
  69. package/src/store/createStateSourceAction.test.ts +62 -0
  70. package/src/store/createStateSourceAction.ts +34 -39
  71. package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
  72. package/src/telemetry/devMode.test.ts +52 -0
  73. package/src/telemetry/devMode.ts +40 -0
  74. package/src/telemetry/initTelemetry.test.ts +225 -0
  75. package/src/telemetry/initTelemetry.ts +205 -0
  76. package/src/telemetry/telemetryManager.test.ts +263 -0
  77. package/src/telemetry/telemetryManager.ts +187 -0
  78. package/src/users/usersStore.test.ts +1 -0
  79. package/src/users/usersStore.ts +5 -1
  80. package/src/utils/createFetcherStore.test.ts +6 -4
  81. package/src/utils/createFetcherStore.ts +2 -1
  82. package/src/utils/getStagingApiHost.test.ts +21 -0
  83. package/src/utils/getStagingApiHost.ts +14 -0
  84. package/src/utils/ids.test.ts +1 -29
  85. package/src/utils/ids.ts +0 -10
  86. package/src/utils/setCleanupTimeout.ts +24 -0
  87. package/src/preview/previewQuery.test.ts +0 -236
  88. package/src/preview/previewQuery.ts +0 -153
  89. package/src/preview/previewStore.test.ts +0 -36
  90. package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
  91. package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
  92. package/src/preview/util.ts +0 -13
@@ -0,0 +1,179 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {createSanityInstance} from '../store/createSanityInstance'
5
+ import {normalizeMedia, transformProjectionToPreview} from './previewProjectionUtils'
6
+ import {type PreviewQueryResult} from './types'
7
+
8
+ // Mock the getClient function
9
+ vi.mock('../client/clientStore', () => ({
10
+ getClient: vi.fn(),
11
+ }))
12
+
13
+ const mockClient = {
14
+ config: () => ({projectId: 'test-project', dataset: 'test-dataset'}),
15
+ } as unknown as SanityClient
16
+
17
+ describe('normalizeMedia', () => {
18
+ beforeEach(async () => {
19
+ vi.clearAllMocks()
20
+ })
21
+
22
+ it('returns null if media is null or undefined', () => {
23
+ expect(normalizeMedia(null, mockClient)).toBeNull()
24
+ expect(normalizeMedia(undefined, mockClient)).toBeNull()
25
+ })
26
+
27
+ it('returns null if media does not have a valid asset', () => {
28
+ const invalidMedia1 = {media: {_ref: 'image-abc123-200x200-png'}} // Missing `asset` property
29
+ const invalidMedia2 = {asset: {ref: 'image-abc123-200x200-png'}} // Incorrect property name `ref`
30
+ expect(normalizeMedia(invalidMedia1, mockClient)).toBeNull()
31
+ expect(normalizeMedia(invalidMedia2, mockClient)).toBeNull()
32
+ })
33
+
34
+ it('returns null if media is not an object', () => {
35
+ expect(normalizeMedia(123, mockClient)).toBeNull()
36
+ expect(normalizeMedia('invalid', mockClient)).toBeNull()
37
+ })
38
+
39
+ it('returns a normalized URL for valid image asset objects', () => {
40
+ const validMedia = {type: 'image-asset', _ref: 'image-abc123-200x200-png'}
41
+ const result = normalizeMedia(validMedia, mockClient)
42
+ expect(result).toEqual({
43
+ type: 'image-asset',
44
+ _ref: 'image-abc123-200x200-png',
45
+ url: 'https://cdn.sanity.io/images/test-project/test-dataset/abc123-200x200.png',
46
+ })
47
+ })
48
+
49
+ it('handles image assets with expected URL format', () => {
50
+ const media = {type: 'image-asset', _ref: 'image-xyz456-400x400-jpg'}
51
+ const result = normalizeMedia(media, mockClient)
52
+ expect(result).toEqual({
53
+ type: 'image-asset',
54
+ _ref: 'image-xyz456-400x400-jpg',
55
+ url: 'https://cdn.sanity.io/images/test-project/test-dataset/xyz456-400x400.jpg',
56
+ })
57
+ })
58
+ })
59
+
60
+ describe('transformProjectionToPreview', () => {
61
+ const instance = createSanityInstance({
62
+ projectId: 'test-project',
63
+ dataset: 'test-dataset',
64
+ })
65
+
66
+ beforeEach(async () => {
67
+ vi.clearAllMocks()
68
+
69
+ // Mock getClient to return our mock client
70
+ const {getClient} = await import('../client/clientStore')
71
+ vi.mocked(getClient).mockReturnValue(mockClient)
72
+ })
73
+
74
+ it('transforms projection result with title and subtitle', () => {
75
+ const projectionResult: PreviewQueryResult = {
76
+ _id: 'doc1',
77
+ _type: 'article',
78
+ _updatedAt: '2026-01-01',
79
+ titleCandidates: {title: 'My Title'},
80
+ subtitleCandidates: {description: 'My Description'},
81
+ media: null,
82
+ }
83
+
84
+ const result = transformProjectionToPreview(instance, projectionResult)
85
+
86
+ expect(result).toEqual({
87
+ title: 'My Title',
88
+ subtitle: 'My Description',
89
+ media: null,
90
+ })
91
+ })
92
+
93
+ it('uses fallback title when no title candidates exist', () => {
94
+ const projectionResult: PreviewQueryResult = {
95
+ _id: 'doc1',
96
+ _type: 'article',
97
+ _updatedAt: '2026-01-01',
98
+ titleCandidates: {},
99
+ subtitleCandidates: {},
100
+ media: null,
101
+ }
102
+
103
+ const result = transformProjectionToPreview(instance, projectionResult)
104
+
105
+ expect(result.title).toBe('article: doc1')
106
+ expect(result.subtitle).toBeUndefined()
107
+ })
108
+
109
+ it('transforms projection result with media', () => {
110
+ const projectionResult: PreviewQueryResult = {
111
+ _id: 'doc1',
112
+ _type: 'article',
113
+ _updatedAt: '2026-01-01',
114
+ titleCandidates: {title: 'My Title'},
115
+ subtitleCandidates: {},
116
+ media: {type: 'image-asset', _ref: 'image-abc123-200x200-png', url: ''},
117
+ }
118
+
119
+ const result = transformProjectionToPreview(instance, projectionResult)
120
+
121
+ expect(result).toEqual({
122
+ title: 'My Title',
123
+ subtitle: undefined,
124
+ media: {
125
+ type: 'image-asset',
126
+ _ref: 'image-abc123-200x200-png',
127
+ url: 'https://cdn.sanity.io/images/test-project/test-dataset/abc123-200x200.png',
128
+ },
129
+ })
130
+ })
131
+
132
+ it('includes status when provided', () => {
133
+ const projectionResult: PreviewQueryResult = {
134
+ _id: 'doc1',
135
+ _type: 'article',
136
+ _updatedAt: '2026-01-01',
137
+ titleCandidates: {title: 'My Title'},
138
+ subtitleCandidates: {},
139
+ media: null,
140
+ _status: {
141
+ lastEditedPublishedAt: '2026-01-01',
142
+ lastEditedDraftAt: '2026-01-02',
143
+ },
144
+ }
145
+
146
+ const result = transformProjectionToPreview(instance, projectionResult)
147
+
148
+ expect(result).toEqual({
149
+ title: 'My Title',
150
+ subtitle: undefined,
151
+ media: null,
152
+ _status: {
153
+ lastEditedPublishedAt: '2026-01-01',
154
+ lastEditedDraftAt: '2026-01-02',
155
+ },
156
+ })
157
+ })
158
+
159
+ it('calls getClient with the provided source', async () => {
160
+ const projectionResult: PreviewQueryResult = {
161
+ _id: 'doc1',
162
+ _type: 'article',
163
+ _updatedAt: '2026-01-01',
164
+ titleCandidates: {title: 'My Title'},
165
+ subtitleCandidates: {},
166
+ media: null,
167
+ }
168
+
169
+ const source = {mediaLibraryId: 'test-library'}
170
+
171
+ transformProjectionToPreview(instance, projectionResult, source)
172
+
173
+ const {getClient} = await import('../client/clientStore')
174
+ expect(getClient).toHaveBeenCalledWith(instance, {
175
+ apiVersion: 'v2025-05-06',
176
+ source,
177
+ })
178
+ })
179
+ })
@@ -0,0 +1,93 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {createImageUrlBuilder} from '@sanity/image-url'
3
+ import {isObject} from 'lodash-es'
4
+
5
+ import {getClient} from '../client/clientStore'
6
+ import {type DocumentSource, isDatasetSource} from '../config/sanityConfig'
7
+ import {type SanityInstance} from '../store/createSanityInstance'
8
+ import {SUBTITLE_CANDIDATES, TITLE_CANDIDATES} from './previewConstants'
9
+ import {type PreviewQueryResult, type PreviewValue} from './types'
10
+
11
+ const API_VERSION = 'v2025-05-06'
12
+
13
+ /**
14
+ * Checks if the provided value has `_ref` property that is a string and starts with `image-`
15
+ */
16
+ function hasImageRef<T>(value: unknown): value is T & {_ref: string} {
17
+ return isObject(value) && '_ref' in value && typeof (value as {_ref: unknown})._ref === 'string'
18
+ }
19
+
20
+ /**
21
+ * Normalizes a media asset to a preview value.
22
+ * Adds a url to a media asset reference using `@sanity/image-url`.
23
+ *
24
+ * @internal
25
+ */
26
+ export function normalizeMedia(media: unknown, client: SanityClient): PreviewValue['media'] {
27
+ if (!media) return null
28
+ if (!hasImageRef(media)) return null
29
+
30
+ const builder = createImageUrlBuilder(client)
31
+ const url = builder.image({_ref: media._ref}).url()
32
+
33
+ return {
34
+ type: 'image-asset',
35
+ _ref: media._ref,
36
+ url,
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Finds a single field value from a set of candidates based on a priority list of field names.
42
+ * Returns the first non-empty string value found from the candidates matching the priority list order.
43
+ *
44
+ * @internal
45
+ */
46
+ function findFirstDefined(
47
+ fieldsToSearch: string[],
48
+ candidates: Record<string, unknown>,
49
+ exclude?: unknown,
50
+ ): string | undefined {
51
+ if (!candidates) return undefined
52
+
53
+ for (const field of fieldsToSearch) {
54
+ const value = candidates[field]
55
+ if (typeof value === 'string' && value.trim() !== '' && value !== exclude) {
56
+ return value
57
+ }
58
+ }
59
+
60
+ return undefined
61
+ }
62
+
63
+ /**
64
+ * Transforms a projection result (with titleCandidates, subtitleCandidates, media)
65
+ * into a PreviewValue (with title, subtitle, media).
66
+ *
67
+ * @param projectionResult - The raw projection result from GROQ
68
+ * @param instance - The Sanity instance to use for client configuration
69
+ * @param source - Data source for the preview
70
+ * @internal
71
+ */
72
+ export function transformProjectionToPreview(
73
+ instance: SanityInstance,
74
+ projectionResult: PreviewQueryResult,
75
+ source?: DocumentSource,
76
+ ): PreviewValue {
77
+ const title = findFirstDefined(TITLE_CANDIDATES, projectionResult.titleCandidates)
78
+ const subtitle = findFirstDefined(SUBTITLE_CANDIDATES, projectionResult.subtitleCandidates, title)
79
+
80
+ // Get a client for the source (if provided) or use the instance config
81
+ const client = getClient(instance, {
82
+ apiVersion: API_VERSION,
83
+ // TODO: remove in v3 when we're ready for everything to be queried via source
84
+ source: source && !isDatasetSource(source) ? source : undefined,
85
+ })
86
+
87
+ return {
88
+ title: String(title || `${projectionResult._type}: ${projectionResult._id}`),
89
+ subtitle: subtitle || undefined,
90
+ media: normalizeMedia(projectionResult.media, client),
91
+ ...(projectionResult._status && {_status: projectionResult._status}),
92
+ }
93
+ }
@@ -1,28 +1,17 @@
1
- import {of} from 'rxjs'
2
- import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
1
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
2
 
4
- import {createDocumentHandle} from '../config/handles'
3
+ import {resolveProjection} from '../projection/resolveProjection'
4
+ import {type ProjectionValuePending} from '../projection/types'
5
5
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
6
- import {type StateSource} from '../store/createStateSourceAction'
7
- import {getPreviewState} from './getPreviewState'
8
- import {type PreviewValue, type ValuePending} from './previewStore'
9
6
  import {resolvePreview} from './resolvePreview'
7
+ import {type PreviewQueryResult} from './types'
10
8
 
11
- vi.mock('./getPreviewState')
9
+ vi.mock('../projection/resolveProjection')
12
10
 
13
11
  describe('resolvePreview', () => {
14
12
  let instance: SanityInstance
15
-
16
13
  beforeEach(() => {
17
- vi.resetAllMocks()
18
- // Create a mock that returns the correct ValuePending type
19
- vi.mocked(getPreviewState).mockReturnValue({
20
- observable: of({
21
- data: {title: 'test'},
22
- isPending: false,
23
- } as ValuePending<PreviewValue>),
24
- } as StateSource<ValuePending<PreviewValue>>)
25
-
14
+ vi.clearAllMocks()
26
15
  instance = createSanityInstance({projectId: 'p', dataset: 'd'})
27
16
  })
28
17
 
@@ -30,18 +19,46 @@ describe('resolvePreview', () => {
30
19
  instance.dispose()
31
20
  })
32
21
 
33
- it('resolves a preview and returns the first emitted value with results', async () => {
34
- const docHandle = createDocumentHandle({
35
- documentId: 'doc123',
36
- documentType: 'movie',
22
+ it('resolves and transforms projection result to preview format', async () => {
23
+ const mockProjectionResult: PreviewQueryResult = {
24
+ _id: 'doc1',
25
+ _type: 'article',
26
+ _updatedAt: '2024-01-01',
27
+ titleCandidates: {title: 'Resolved Title'},
28
+ subtitleCandidates: {description: 'Resolved Description'},
29
+ media: null,
30
+ }
31
+
32
+ vi.mocked(resolveProjection).mockResolvedValue({
33
+ data: mockProjectionResult,
34
+ isPending: false,
35
+ } as ProjectionValuePending<PreviewQueryResult>)
36
+
37
+ const result = await resolvePreview(instance, {
38
+ documentId: 'doc1',
39
+ documentType: 'article',
37
40
  })
38
41
 
39
- const result = await resolvePreview(instance, docHandle)
42
+ expect(result.data).toEqual({
43
+ title: 'Resolved Title',
44
+ subtitle: 'Resolved Description',
45
+ media: null,
46
+ })
47
+ expect(result.isPending).toBe(false)
48
+ })
40
49
 
41
- expect(getPreviewState).toHaveBeenCalledWith(instance, docHandle)
42
- expect(result).toEqual({
43
- data: {title: 'test'},
50
+ it('returns null data when projection resolves with null', async () => {
51
+ vi.mocked(resolveProjection).mockResolvedValue({
52
+ data: null,
44
53
  isPending: false,
45
54
  })
55
+
56
+ const result = await resolvePreview(instance, {
57
+ documentId: 'doc1',
58
+ documentType: 'article',
59
+ })
60
+
61
+ expect(result.data).toBeNull()
62
+ expect(result.isPending).toBe(false)
46
63
  })
47
64
  })
@@ -1,20 +1,39 @@
1
- import {filter, firstValueFrom} from 'rxjs'
2
-
3
1
  import {type DocumentHandle} from '../config/sanityConfig'
4
- import {bindActionByDataset} from '../store/createActionBinder'
5
- import {getPreviewState} from './getPreviewState'
6
- import {previewStore} from './previewStore'
2
+ import {resolveProjection} from '../projection/resolveProjection'
3
+ import {type SanityInstance} from '../store/createSanityInstance'
4
+ import {PREVIEW_PROJECTION} from './previewConstants'
5
+ import {transformProjectionToPreview} from './previewProjectionUtils'
6
+ import {type PreviewQueryResult, type PreviewValue, type ValuePending} from './types'
7
7
 
8
8
  /**
9
9
  * @beta
10
+ * @deprecated This type is deprecated and will be removed in a future release.
10
11
  */
11
12
  export type ResolvePreviewOptions = DocumentHandle
12
13
 
13
14
  /**
14
15
  * @beta
16
+ * @deprecated This function is deprecated and will be removed in a future release.
15
17
  */
16
- export const resolvePreview = bindActionByDataset(
17
- previewStore,
18
- ({instance}, docHandle: ResolvePreviewOptions) =>
19
- firstValueFrom(getPreviewState(instance, docHandle).observable.pipe(filter((i) => !!i.data))),
20
- )
18
+ export async function resolvePreview(
19
+ instance: SanityInstance,
20
+ options: ResolvePreviewOptions,
21
+ ): Promise<ValuePending<PreviewValue>> {
22
+ // Resolve the projection
23
+ const projectionResult = await resolveProjection<PreviewQueryResult>(instance, {
24
+ ...options,
25
+ projection: PREVIEW_PROJECTION,
26
+ })
27
+
28
+ if (!projectionResult.data) {
29
+ return {data: null, isPending: projectionResult.isPending}
30
+ }
31
+
32
+ // Transform to preview format
33
+ const previewValue = transformProjectionToPreview(instance, projectionResult.data, options.source)
34
+
35
+ return {
36
+ data: previewValue,
37
+ isPending: projectionResult.isPending,
38
+ }
39
+ }
@@ -1,7 +1,9 @@
1
- import {type BoundDatasetKey} from '../store/createActionBinder'
2
- import {defineStore} from '../store/defineStore'
3
- import {subscribeToStateAndFetchBatches} from './subscribeToStateAndFetchBatches'
1
+ import {type DocumentStatus} from '../projection/types'
4
2
 
3
+ /**
4
+ *
5
+ * @internal
6
+ */
5
7
  export interface PreviewQueryResult {
6
8
  _id: string
7
9
  _type: string
@@ -9,6 +11,7 @@ export interface PreviewQueryResult {
9
11
  titleCandidates: Record<string, unknown>
10
12
  subtitleCandidates: Record<string, unknown>
11
13
  media?: PreviewMedia | null
14
+ _status?: DocumentStatus
12
15
  }
13
16
 
14
17
  /**
@@ -74,22 +77,10 @@ export type ValuePending<T> = {
74
77
 
75
78
  /**
76
79
  * @public
80
+ * @deprecated This interface is kept for backwards compatibility but is no longer used internally.
81
+ * Preview state is now stored in the projection store.
77
82
  */
78
83
  export interface PreviewStoreState {
79
84
  values: {[TDocumentId in string]?: ValuePending<PreviewValue>}
80
85
  subscriptions: {[TDocumentId in string]?: {[TSubscriptionId in string]?: true}}
81
86
  }
82
-
83
- export const previewStore = defineStore<PreviewStoreState, BoundDatasetKey>({
84
- name: 'Preview',
85
- getInitialState() {
86
- return {
87
- subscriptions: {},
88
- values: {},
89
- }
90
- },
91
- initialize: (context) => {
92
- const subscription = subscribeToStateAndFetchBatches(context)
93
- return () => subscription.unsubscribe
94
- },
95
- })
@@ -126,7 +126,7 @@ describe('getProjectionState', () => {
126
126
  expect(state.get().subscriptions).toEqual({})
127
127
  expect(state.get().documentProjections).toEqual({})
128
128
 
129
- const unsubscribe1 = projectionState1.subscribe(vi.fn()) // Should use ID 3
129
+ const unsubscribe1 = projectionState1.subscribe(vi.fn()) // Should use ID 2
130
130
  expect(state.get().subscriptions).toEqual({
131
131
  [docHandle.documentId]: {[hash1]: {testSubId_2: true}},
132
132
  })
@@ -134,7 +134,7 @@ describe('getProjectionState', () => {
134
134
  [docHandle.documentId]: {[hash1]: projection1},
135
135
  })
136
136
 
137
- const unsubscribe2 = projectionState2.subscribe(vi.fn()) // Should use ID 4
137
+ const unsubscribe2 = projectionState2.subscribe(vi.fn()) // Should use ID 3
138
138
  expect(state.get().subscriptions).toEqual({
139
139
  [docHandle.documentId]: {
140
140
  [hash1]: {testSubId_2: true},
@@ -148,10 +148,12 @@ describe('getProjectionState', () => {
148
148
  },
149
149
  })
150
150
 
151
- const unsubscribe3 = projectionState1.subscribe(vi.fn()) // Should use ID 5
151
+ // should share ID 2 from the first subscription -- we only care if ANY subscription exists
152
+ // so we can include the document / projection in a bulk fetch
153
+ const unsubscribe3 = projectionState1.subscribe(vi.fn())
152
154
  expect(state.get().subscriptions).toEqual({
153
155
  [docHandle.documentId]: {
154
- [hash1]: {testSubId_2: true, testSubId_4: true},
156
+ [hash1]: {testSubId_2: true},
155
157
  [hash2]: {testSubId_3: true},
156
158
  },
157
159
  })
@@ -165,23 +167,20 @@ describe('getProjectionState', () => {
165
167
  })
166
168
 
167
169
  // --- Test Unsubscribe ---
168
- unsubscribe1() // Unsubscribes ID 3
169
- expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toEqual({
170
- testSubId_2: true,
171
- testSubId_4: true,
172
- })
170
+ unsubscribe1() // Unsubscribes first subscription consumer
171
+ expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toEqual({testSubId_2: true})
173
172
  vi.advanceTimersByTime(PROJECTION_STATE_CLEAR_DELAY)
174
- expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toEqual({testSubId_4: true})
173
+ expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toEqual({testSubId_2: true})
175
174
  expect(state.get().documentProjections[docHandle.documentId]?.[hash1]).toEqual(projection1)
176
175
 
177
- unsubscribe3() // Unsubscribes ID 5
176
+ unsubscribe3() // Also unsubscibes first projection subscription, should be removed
178
177
  vi.advanceTimersByTime(PROJECTION_STATE_CLEAR_DELAY)
179
178
  expect(state.get().subscriptions[docHandle.documentId]?.[hash1]).toBeUndefined()
180
179
  expect(state.get().documentProjections[docHandle.documentId]?.[hash1]).toBeUndefined()
181
180
  expect(state.get().subscriptions[docHandle.documentId]?.[hash2]).toEqual({testSubId_3: true})
182
181
  expect(state.get().documentProjections[docHandle.documentId]?.[hash2]).toEqual(projection2)
183
182
 
184
- unsubscribe2() // Unsubscribes ID 4
183
+ unsubscribe2() // Unsubscribes second projection subscription, should be removed
185
184
  vi.advanceTimersByTime(PROJECTION_STATE_CLEAR_DELAY)
186
185
  expect(state.get().subscriptions[docHandle.documentId]).toBeUndefined()
187
186
  expect(state.get().documentProjections[docHandle.documentId]).toBeUndefined()
@@ -203,14 +202,14 @@ describe('getProjectionState', () => {
203
202
  }))
204
203
 
205
204
  const unsubscribe1 = projectionState.subscribe(vi.fn()) // Should use ID 2
206
- const unsubscribe2 = projectionState.subscribe(vi.fn()) // Should use ID 3
205
+ const unsubscribe2 = projectionState.subscribe(vi.fn()) // Should reuse ID 2
207
206
 
208
207
  expect(state.get().values[docHandle.documentId]?.[hash]).toEqual({
209
208
  data: initialData,
210
209
  isPending: true,
211
210
  })
212
211
 
213
- unsubscribe1() // Unsubscribes ID 2
212
+ unsubscribe1() // Unsubscribes first subscription consumer
214
213
  vi.advanceTimersByTime(PROJECTION_STATE_CLEAR_DELAY)
215
214
  expect(state.get().values[docHandle.documentId]?.[hash]).toEqual({
216
215
  data: initialData,
@@ -219,9 +218,10 @@ describe('getProjectionState', () => {
219
218
  expect(Object.keys(state.get().subscriptions[docHandle.documentId]?.[hash] ?? {}).length).toBe(
220
219
  1,
221
220
  )
222
- expect(state.get().subscriptions[docHandle.documentId]?.[hash]).toEqual({testSubId_3: true})
221
+ // should still have the same subscription ID since we're not unsubscribing the second consumer
222
+ expect(state.get().subscriptions[docHandle.documentId]?.[hash]).toEqual({testSubId_2: true})
223
223
 
224
- unsubscribe2() // Unsubscribes ID 3
224
+ unsubscribe2() // Unsubscribes second subscription consumer
225
225
  expect(state.get().values[docHandle.documentId]?.[hash]).toEqual({
226
226
  data: initialData,
227
227
  isPending: true,
@@ -12,6 +12,7 @@ import {
12
12
  } from '../store/createStateSourceAction'
13
13
  import {hashString} from '../utils/hashString'
14
14
  import {insecureRandomId} from '../utils/ids'
15
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
15
16
  import {projectionStore} from './projectionStore'
16
17
  import {type ProjectionStoreState, type ProjectionValuePending} from './types'
17
18
  import {PROJECTION_STATE_CLEAR_DELAY, STABLE_EMPTY_PROJECTION, validateProjection} from './util'
@@ -110,7 +111,7 @@ export const _getProjectionState = bindActionBySourceAndPerspective(
110
111
  }))
111
112
 
112
113
  return () => {
113
- setTimeout(() => {
114
+ setCleanupTimeout(() => {
114
115
  state.set('removeSubscription', (prev): Partial<ProjectionStoreState> => {
115
116
  const documentSubscriptionsForHash = omit(
116
117
  prev.subscriptions[documentId]?.[projectionHash],
@@ -1,8 +1,7 @@
1
1
  import {type ClientPerspective} from '@sanity/client'
2
- import {DocumentId} from '@sanity/id-utils'
2
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
3
3
 
4
4
  import {type ReleasePerspective} from '../config/sanityConfig'
5
- import {getPublishedId} from '../utils/ids'
6
5
  import {
7
6
  type DocumentProjections,
8
7
  type DocumentProjectionValues,
@@ -85,7 +84,7 @@ export function processProjectionQuery({
85
84
  } = {}
86
85
 
87
86
  for (const result of results) {
88
- const originalId = getPublishedId(result._id)
87
+ const originalId = getPublishedId(DocumentId(result._id))
89
88
  const hash = result.__projectionHash
90
89
 
91
90
  if (!ids.has(originalId)) continue
@@ -29,7 +29,7 @@ interface DocumentProjectionSubscriptions {
29
29
  }
30
30
  }
31
31
 
32
- interface DocumentStatus {
32
+ export interface DocumentStatus {
33
33
  lastEditedDraftAt?: string
34
34
  lastEditedPublishedAt?: string
35
35
  lastEditedVersionAt?: string
@@ -45,6 +45,7 @@ import {
45
45
  import {type StoreState} from '../store/createStoreState'
46
46
  import {defineStore, type StoreContext} from '../store/defineStore'
47
47
  import {insecureRandomId} from '../utils/ids'
48
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
48
49
  import {
49
50
  QUERY_STATE_CLEAR_DELAY,
50
51
  QUERY_STORE_API_VERSION,
@@ -333,7 +334,7 @@ const _getQueryState = bindActionBySource(
333
334
 
334
335
  return () => {
335
336
  // this runs on unsubscribe
336
- setTimeout(
337
+ setCleanupTimeout(
337
338
  () => state.set('removeSubscriber', removeSubscriber(key, subscriptionId)),
338
339
  QUERY_STATE_CLEAR_DELAY,
339
340
  )
@@ -1,7 +1,7 @@
1
1
  import {createSelector} from 'reselect'
2
2
 
3
- import {type PerspectiveHandle} from '../config/sanityConfig'
4
- import {bindActionByDataset, type BoundStoreAction} from '../store/createActionBinder'
3
+ import {type DocumentSource, type PerspectiveHandle} from '../config/sanityConfig'
4
+ import {bindActionBySource, type BoundStoreAction} from '../store/createActionBinder'
5
5
  import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction'
6
6
  /*
7
7
  * Although this is an import dependency cycle, it is not a logical cycle:
@@ -26,7 +26,7 @@ const selectActiveReleases = (context: SelectorContext<ReleasesStoreState>) =>
26
26
  context.state.activeReleases
27
27
  const selectOptions = (
28
28
  _context: SelectorContext<ReleasesStoreState>,
29
- options: PerspectiveHandle & {projectId?: string; dataset?: string},
29
+ options: PerspectiveHandle & {projectId?: string; dataset?: string; source?: DocumentSource},
30
30
  ) => options
31
31
 
32
32
  const memoizedOptionsSelector = createSelector(
@@ -103,12 +103,13 @@ let _boundGetPerspectiveState: BoundGetPerspectiveState | undefined
103
103
  *
104
104
  * @public
105
105
  */
106
- export const getPerspectiveState: BoundGetPerspectiveState = (...args) => {
106
+ export const getPerspectiveState: BoundGetPerspectiveState = (instance, ...rest) => {
107
107
  if (!_boundGetPerspectiveState) {
108
- _boundGetPerspectiveState = bindActionByDataset(
108
+ _boundGetPerspectiveState = bindActionBySource(
109
109
  releasesStore,
110
110
  _getPerspectiveStateSelector,
111
111
  ) as BoundGetPerspectiveState
112
112
  }
113
- return _boundGetPerspectiveState(...args)
113
+ // bindActionBySource keyFn destructures { source } from the first param, so pass {} when no options
114
+ return _boundGetPerspectiveState(instance, ...(rest.length ? rest : [{}]))
114
115
  }