@sanity/sdk 0.0.0-rc.6 → 0.0.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 (53) hide show
  1. package/README.md +7 -15
  2. package/dist/index.d.ts +562 -234
  3. package/dist/index.js +515 -256
  4. package/dist/index.js.map +1 -1
  5. package/package.json +12 -10
  6. package/src/_exports/index.ts +17 -2
  7. package/src/auth/dashboardUtils.test.ts +41 -0
  8. package/src/auth/dashboardUtils.ts +12 -0
  9. package/src/auth/getOrganizationVerificationState.test.ts +197 -0
  10. package/src/auth/getOrganizationVerificationState.ts +73 -0
  11. package/src/auth/handleAuthCallback.test.ts +2 -0
  12. package/src/auth/handleAuthCallback.ts +1 -0
  13. package/src/auth/logout.test.ts +1 -0
  14. package/src/auth/logout.ts +1 -0
  15. package/src/auth/refreshStampedToken.ts +1 -0
  16. package/src/auth/studioModeAuth.test.ts +1 -1
  17. package/src/auth/studioModeAuth.ts +1 -0
  18. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -0
  19. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -0
  20. package/src/client/clientStore.ts +22 -18
  21. package/src/comlink/node/actions/releaseNode.ts +16 -14
  22. package/src/config/__tests__/handles.test.ts +30 -0
  23. package/src/config/handles.ts +67 -0
  24. package/src/config/sanityConfig.ts +44 -16
  25. package/src/document/actions.ts +188 -60
  26. package/src/document/applyDocumentActions.ts +12 -5
  27. package/src/document/documentStore.test.ts +70 -121
  28. package/src/document/documentStore.ts +57 -27
  29. package/src/document/patchOperations.test.ts +1 -1
  30. package/src/document/patchOperations.ts +39 -39
  31. package/src/document/sharedListener.ts +3 -1
  32. package/src/favorites/favorites.test.ts +237 -0
  33. package/src/favorites/favorites.ts +122 -0
  34. package/src/preview/resolvePreview.test.ts +3 -4
  35. package/src/preview/subscribeToStateAndFetchBatches.test.ts +1 -1
  36. package/src/preview/subscribeToStateAndFetchBatches.ts +4 -2
  37. package/src/project/organizationVerification.test.ts +35 -0
  38. package/src/project/organizationVerification.ts +26 -0
  39. package/src/projection/getProjectionState.ts +36 -11
  40. package/src/projection/resolveProjection.test.ts +3 -4
  41. package/src/projection/resolveProjection.ts +35 -9
  42. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  43. package/src/projection/subscribeToStateAndFetchBatches.ts +4 -2
  44. package/src/query/queryStore.test.ts +12 -12
  45. package/src/query/queryStore.ts +71 -42
  46. package/src/releases/getPerspectiveState.test.ts +192 -0
  47. package/src/releases/getPerspectiveState.ts +93 -0
  48. package/src/releases/releasesStore.test.ts +170 -0
  49. package/src/releases/releasesStore.ts +89 -0
  50. package/src/releases/utils/sortReleases.test.ts +336 -0
  51. package/src/releases/utils/sortReleases.ts +48 -0
  52. package/src/utils/listenQuery.test.ts +302 -0
  53. package/src/utils/listenQuery.ts +128 -0
@@ -7,7 +7,6 @@ import {
7
7
  type KeyedSegment,
8
8
  type Path,
9
9
  type PathSegment,
10
- type SanityDocumentLike,
11
10
  } from '@sanity/types'
12
11
 
13
12
  type SingleValuePath = Exclude<PathSegment, IndexTuple>[]
@@ -17,7 +16,7 @@ type ToNumber<TInput extends string> = TInput extends `${infer TNumber extends n
17
16
  : TInput
18
17
 
19
18
  /**
20
- * Parse a single segment that may include bracket parts.
19
+ * Parse a single "segment" that may include bracket parts.
21
20
  *
22
21
  * For example, the literal
23
22
  *
@@ -42,7 +41,7 @@ type ParseSegment<TInput extends string> = TInput extends `${infer TProp}[${infe
42
41
  /**
43
42
  * Parse one or more bracketed parts from a segment.
44
43
  *
45
- * It recursively peels off a bracketed part and then continues.
44
+ * It recursively "peels off" a bracketed part and then continues.
46
45
  *
47
46
  * For example, given the string:
48
47
  *
@@ -61,7 +60,7 @@ type ParseBracket<TInput extends string> = TInput extends `[${infer TPart}]${inf
61
60
  : [] // no leading bracket → end of this segment
62
61
 
63
62
  /**
64
- * Split the entire path string on dots outside of any brackets.
63
+ * Split the entire path string on dots "outside" of any brackets.
65
64
  *
66
65
  * For example:
67
66
  * ```
@@ -76,42 +75,43 @@ type ParseBracket<TInput extends string> = TInput extends `[${infer TPart}]${inf
76
75
  *
77
76
  * (We use a simple recursion that splits on the first dot.)
78
77
  */
79
- type PathParts<TPath extends string> = TPath extends `${infer TLeft}.${infer TRight}`
80
- ? [...ParseSegment<TLeft>, ...PathParts<TRight>]
81
- : ParseSegment<TPath>
78
+ type PathParts<TPath extends string> = TPath extends `${infer Head}.${infer Tail}`
79
+ ? [Head, ...PathParts<Tail>]
80
+ : TPath extends ''
81
+ ? []
82
+ : [TPath]
82
83
 
83
84
  /**
84
- * Given a type T and an array of access keys Parts, recursively index into T.
85
+ * Given a type T and an array of "access keys" Parts, recursively index into T.
85
86
  *
86
87
  * If a part is a key, it looks up that property.
87
- * If T is an array and the part is a number, it indexes into the element type.
88
+ * If T is an array and the part is a number, it "indexes" into the element type.
88
89
  */
89
- type DeepGet<T, TParts extends readonly unknown[]> = TParts extends [infer Head, ...infer Tail]
90
- ? Head extends keyof T
91
- ? DeepGet<T[Head], Tail>
92
- : T extends Array<infer U>
93
- ? Head extends number
94
- ? DeepGet<U, Tail>
95
- : never
96
- : never
97
- : T
90
+ type DeepGet<TValue, TPath extends readonly (string | number)[]> = TPath extends []
91
+ ? TValue
92
+ : TPath extends readonly [infer THead, ...infer TTail]
93
+ ? // Handle traversing into optional properties
94
+ DeepGet<
95
+ TValue extends undefined | null
96
+ ? undefined // Stop traversal if current value is null/undefined
97
+ : THead extends keyof TValue // Access property if key exists
98
+ ? TValue[THead]
99
+ : // Handle array indexing
100
+ THead extends number
101
+ ? TValue extends readonly (infer TElement)[]
102
+ ? TElement | undefined // Array element or undefined if out of bounds
103
+ : undefined // Cannot index non-array with number
104
+ : undefined, // Key/index doesn't exist
105
+ TTail extends readonly (string | number)[] ? TTail : [] // Continue with the rest of the path
106
+ >
107
+ : never // Should be unreachable
98
108
 
99
109
  /**
100
110
  * Given a document type TDocument and a JSON Match path string TPath,
101
111
  * compute the type found at that path.
102
112
  * @beta
103
113
  */
104
- export type JsonMatch<TDocument extends SanityDocumentLike, TPath extends string> = DeepGet<
105
- TDocument,
106
- PathParts<TPath>
107
- >
108
-
109
- /**
110
- * Computing the full possible paths may be possible but is hard to compute
111
- * within the type system for complex document types so we use string.
112
- * @beta
113
- */
114
- export type JsonMatchPath<_TDocument extends SanityDocumentLike> = string
114
+ export type JsonMatch<TDocument, TPath extends string> = DeepGet<TDocument, PathParts<TPath>>
115
115
 
116
116
  function parseBracketContent(content: string): PathSegment {
117
117
  // 1) Range match: ^(\d*):(\d*)$
@@ -138,7 +138,7 @@ function parseBracketContent(content: string): PathSegment {
138
138
  return index
139
139
  }
140
140
 
141
- throw new Error(`Invalid bracket content: [${content}]”`)
141
+ throw new Error(`Invalid bracket content: "[${content}]"`)
142
142
  }
143
143
 
144
144
  function parseSegment(segment: string): PathSegment[] {
@@ -271,10 +271,10 @@ type MatchEntry<T = unknown> = {
271
271
  *
272
272
  * @beta
273
273
  */
274
- export function jsonMatch<
275
- TDocument extends SanityDocumentLike,
276
- TPath extends JsonMatchPath<TDocument>,
277
- >(input: TDocument, path: TPath): MatchEntry<JsonMatch<TDocument, TPath>>[]
274
+ export function jsonMatch<TDocument, TPath extends string>(
275
+ input: TDocument,
276
+ path: TPath,
277
+ ): MatchEntry<JsonMatch<TDocument, TPath>>[]
278
278
  /** @beta */
279
279
  export function jsonMatch<TValue>(input: unknown, path: string): MatchEntry<TValue>[]
280
280
  /** @beta */
@@ -324,7 +324,7 @@ function matchRecursive(value: unknown, path: Path, currentPath: SingleValuePath
324
324
  const startIndex = start === '' ? 0 : start
325
325
  const endIndex = end === '' ? value.length : end
326
326
 
327
- // Well accumulate all matches from each index in the range
327
+ // We'll accumulate all matches from each index in the range
328
328
  let results: MatchEntry[] = []
329
329
 
330
330
  // Decide whether the range is exclusive or inclusive. The example in
@@ -587,15 +587,15 @@ export function insert(input: unknown, insertPatch: InsertPatch): unknown {
587
587
  const pathExpression = (insertPatch as {[K in Operation]?: string} & {items: unknown})[operation]
588
588
  if (typeof pathExpression !== 'string') return input
589
589
 
590
- // Helper to normalize a matched index given the parent arrays length.
590
+ // Helper to normalize a matched index given the parent array's length.
591
591
  function normalizeIndex(index: number, parentLength: number): number {
592
592
  switch (operation) {
593
593
  case 'before':
594
- // A negative index means append (i.e. insert before a hypothetical element
594
+ // A negative index means "append" (i.e. insert before a hypothetical element
595
595
  // beyond the end of the array).
596
596
  return index < 0 ? parentLength : index
597
597
  case 'after':
598
- // For "after", if the matched index is negative, we treat it as prepend”:
598
+ // For "after", if the matched index is negative, we treat it as "prepend":
599
599
  // by convention, we convert it to -1 so that later adding 1 produces 0.
600
600
  return index < 0 ? -1 : index
601
601
  default: // default to 'replace'
@@ -916,7 +916,7 @@ export function setDeep(input: unknown, path: SingleValuePath, value: unknown):
916
916
  ]
917
917
  }
918
918
 
919
- // For keyed segments that arent arrays, do nothing.
919
+ // For keyed segments that aren't arrays, do nothing.
920
920
  if (typeof currentSegment === 'object') return input
921
921
 
922
922
  // For plain objects, update an existing property if it exists…
@@ -66,7 +66,9 @@ export function createFetchDocument(instance: SanityInstance) {
66
66
  return function (documentId: string): Observable<SanityDocument | null> {
67
67
  return getClientState(instance, {apiVersion: API_VERSION}).observable.pipe(
68
68
  switchMap((client) => {
69
- const loadDocument = createDocumentLoaderFromClient(client)
69
+ // TODO: remove this once the client is updated to v7 the new type is available in @sanity/mutate/_unstable_store
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ const loadDocument = createDocumentLoaderFromClient(client as any)
70
72
  return loadDocument(documentId)
71
73
  }),
72
74
  map((result) => {
@@ -0,0 +1,237 @@
1
+ import {type Node} from '@sanity/comlink'
2
+ import {firstValueFrom} from 'rxjs'
3
+ import {describe, expect, it, vi} from 'vitest'
4
+
5
+ import {getOrCreateNode, releaseNode} from '../comlink/node/comlinkNodeStore'
6
+ import {type FrameMessage, type WindowMessage} from '../comlink/types'
7
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8
+ import {getFavoritesState, resolveFavoritesState} from './favorites'
9
+
10
+ vi.mock('../comlink/node/comlinkNodeStore')
11
+
12
+ let instance: SanityInstance | undefined
13
+
14
+ describe('favoritesStore', () => {
15
+ const mockContext = {
16
+ documentId: 'doc123',
17
+ documentType: 'movie',
18
+ resourceId: 'res456',
19
+ resourceType: 'studio' as const,
20
+ schemaName: 'movieSchema',
21
+ }
22
+
23
+ const mockContextNoSchema = {
24
+ documentId: 'doc123',
25
+ documentType: 'movie',
26
+ resourceId: 'res456',
27
+ resourceType: 'studio' as const,
28
+ }
29
+
30
+ describe('createFavoriteKey', () => {
31
+ beforeEach(() => {
32
+ vi.resetAllMocks()
33
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
34
+ })
35
+
36
+ afterEach(() => {
37
+ instance?.dispose()
38
+ })
39
+
40
+ it('creates different keys for different contexts with schema name', async () => {
41
+ const mockFetch = vi.fn().mockResolvedValue({isFavorited: false})
42
+ const mockNode = {fetch: mockFetch}
43
+ vi.mocked(getOrCreateNode).mockReturnValue(
44
+ mockNode as unknown as Node<WindowMessage, FrameMessage>,
45
+ )
46
+
47
+ // Make two fetches with different document IDs
48
+ await resolveFavoritesState(instance!, mockContext)
49
+ await resolveFavoritesState(instance!, {
50
+ ...mockContext,
51
+ documentId: 'different',
52
+ })
53
+
54
+ // Verify that the fetch was called with different payloads
55
+ expect(mockFetch).toHaveBeenCalledTimes(2)
56
+ const call1 = mockFetch.mock.calls[0][1]
57
+ const call2 = mockFetch.mock.calls[1][1]
58
+ expect(call1.document.id).toBe('doc123')
59
+ expect(call2.document.id).toBe('different')
60
+ })
61
+
62
+ it('creates different keys for contexts without schema name', async () => {
63
+ const mockFetch = vi.fn().mockResolvedValue({isFavorited: false})
64
+ const mockNode = {fetch: mockFetch}
65
+ vi.mocked(getOrCreateNode).mockReturnValue(
66
+ mockNode as unknown as Node<WindowMessage, FrameMessage>,
67
+ )
68
+
69
+ // Make two fetches with different document IDs
70
+ await resolveFavoritesState(instance!, mockContextNoSchema)
71
+ await resolveFavoritesState(instance!, {
72
+ ...mockContextNoSchema,
73
+ documentId: 'different',
74
+ })
75
+
76
+ // Verify that the fetch was called with different payloads
77
+ expect(mockFetch).toHaveBeenCalledTimes(2)
78
+ const call1 = mockFetch.mock.calls[0][1]
79
+ const call2 = mockFetch.mock.calls[1][1]
80
+ expect(call1.document.id).toBe('doc123')
81
+ expect(call2.document.id).toBe('different')
82
+ expect(call1.document.resource.schemaName).toBeUndefined()
83
+ expect(call2.document.resource.schemaName).toBeUndefined()
84
+ })
85
+ })
86
+
87
+ describe('fetcher', () => {
88
+ beforeEach(() => {
89
+ vi.resetAllMocks()
90
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
91
+ })
92
+
93
+ afterEach(() => {
94
+ instance?.dispose()
95
+ })
96
+
97
+ it('fetches favorite status and handles success', async () => {
98
+ const mockResponse = {isFavorited: true}
99
+ const mockFetch = vi.fn().mockResolvedValue(mockResponse)
100
+ const mockNode = {fetch: mockFetch}
101
+
102
+ vi.mocked(getOrCreateNode).mockReturnValue(
103
+ mockNode as unknown as Node<WindowMessage, FrameMessage>,
104
+ )
105
+
106
+ const result = await resolveFavoritesState(instance!, mockContext)
107
+
108
+ expect(result).toEqual(mockResponse)
109
+ expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/query', {
110
+ document: {
111
+ id: mockContext.documentId,
112
+ type: mockContext.documentType,
113
+ resource: {
114
+ id: mockContext.resourceId,
115
+ type: mockContext.resourceType,
116
+ schemaName: mockContext.schemaName,
117
+ },
118
+ },
119
+ })
120
+ })
121
+
122
+ it('handles error and returns default response', async () => {
123
+ const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'))
124
+ const mockNode = {fetch: mockFetch}
125
+
126
+ vi.mocked(getOrCreateNode).mockReturnValue(
127
+ mockNode as unknown as Node<WindowMessage, FrameMessage>,
128
+ )
129
+
130
+ const result = await resolveFavoritesState(instance!, mockContext)
131
+
132
+ expect(result).toEqual({isFavorited: false})
133
+ })
134
+
135
+ it('shares observable between multiple subscribers and cleans up', async () => {
136
+ const mockResponse = {isFavorited: true}
137
+ const mockFetch = vi.fn().mockResolvedValue(mockResponse)
138
+ const mockNode = {fetch: mockFetch}
139
+
140
+ vi.mocked(getOrCreateNode).mockReturnValue(
141
+ mockNode as unknown as Node<WindowMessage, FrameMessage>,
142
+ )
143
+
144
+ const state = getFavoritesState(instance!, mockContext)
145
+
146
+ // First subscriber
147
+ const sub1 = state.subscribe()
148
+ await firstValueFrom(state.observable)
149
+ expect(mockFetch).toHaveBeenCalledTimes(1)
150
+
151
+ // Second subscriber should use cached response
152
+ const sub2 = state.subscribe()
153
+ await firstValueFrom(state.observable)
154
+ expect(mockFetch).toHaveBeenCalledTimes(1)
155
+
156
+ // Cleanup
157
+ sub1()
158
+ sub2()
159
+
160
+ // Wait for cleanup
161
+ await new Promise((resolve) => setTimeout(resolve, 100))
162
+
163
+ expect(vi.mocked(releaseNode)).toHaveBeenCalledWith(instance, 'dashboard/nodes/sdk')
164
+ })
165
+
166
+ it('reuses active fetch via createFetcherStore/shareReplay when called again while pending', async () => {
167
+ vi.useFakeTimers()
168
+
169
+ let resolveFetch: (value: {isFavorited: boolean}) => void
170
+ const fetchPromise = new Promise<{isFavorited: boolean}>((resolve) => {
171
+ resolveFetch = resolve
172
+ })
173
+ const mockFetch = vi.fn().mockReturnValue(fetchPromise) // Mocks node.fetch
174
+ const mockNode = {fetch: mockFetch}
175
+ vi.mocked(getOrCreateNode).mockReturnValue(
176
+ mockNode as unknown as Node<WindowMessage, FrameMessage>,
177
+ )
178
+
179
+ // Call 1: Triggers the actual fetch
180
+ const promise1 = resolveFavoritesState(instance!, mockContext)
181
+ // Allow fetcher to run and call node.fetch
182
+ await vi.advanceTimersByTimeAsync(1)
183
+ expect(mockFetch).toHaveBeenCalledTimes(1) // node.fetch called once
184
+
185
+ // Call 2: Should reuse the pending fetch via createFetcherStore/shareReplay
186
+ const promise2 = resolveFavoritesState(instance!, mockContext)
187
+ await vi.advanceTimersByTimeAsync(1)
188
+
189
+ // Verify node.fetch was NOT called again
190
+ expect(mockFetch).toHaveBeenCalledTimes(1)
191
+
192
+ // Resolve the underlying fetch
193
+ resolveFetch!({isFavorited: true})
194
+ await vi.advanceTimersByTimeAsync(1) // Allow promises to resolve
195
+
196
+ // Check results
197
+ const result1 = await promise1
198
+ const result2 = await promise2
199
+ expect(result1).toEqual({isFavorited: true})
200
+ expect(result2).toEqual({isFavorited: true})
201
+
202
+ // Allow cleanup timers
203
+ await vi.advanceTimersByTimeAsync(5001) // stateExpirationDelay
204
+ expect(vi.mocked(releaseNode)).toHaveBeenCalled()
205
+
206
+ vi.useRealTimers()
207
+ })
208
+
209
+ it('handles timeout and returns default response', async () => {
210
+ vi.useFakeTimers()
211
+
212
+ const mockFetch = vi.fn().mockReturnValue(new Promise(() => {})) // Promise that never resolves
213
+ const mockNode = {fetch: mockFetch}
214
+
215
+ vi.mocked(getOrCreateNode).mockReturnValue(
216
+ mockNode as unknown as Node<WindowMessage, FrameMessage>,
217
+ )
218
+
219
+ const resultPromise = resolveFavoritesState(instance!, mockContext)
220
+
221
+ // Advance time past the timeout threshold (3000ms)
222
+ await vi.advanceTimersByTimeAsync(3001)
223
+
224
+ const result = await resultPromise
225
+
226
+ expect(result).toEqual({isFavorited: false})
227
+ expect(mockFetch).toHaveBeenCalledTimes(1)
228
+
229
+ // Ensure releaseNode is still called even on timeout/error path
230
+ // Need to wait for the catchError and cleanup logic
231
+ await vi.advanceTimersByTimeAsync(1) // Allow microtasks to run
232
+ expect(vi.mocked(releaseNode)).toHaveBeenCalledWith(instance, 'dashboard/nodes/sdk')
233
+
234
+ vi.useRealTimers()
235
+ })
236
+ })
237
+ })
@@ -0,0 +1,122 @@
1
+ import {
2
+ type CanvasResource,
3
+ type MediaResource,
4
+ SDK_CHANNEL_NAME,
5
+ SDK_NODE_NAME,
6
+ type StudioResource,
7
+ } from '@sanity/message-protocol'
8
+ import {catchError, from, map, Observable, of, shareReplay, throwError, timeout} from 'rxjs'
9
+
10
+ import {getOrCreateNode, releaseNode} from '../comlink/node/comlinkNodeStore'
11
+ import {type DocumentHandle} from '../config/sanityConfig'
12
+ import {type SanityInstance} from '../store/createSanityInstance'
13
+ import {createFetcherStore} from '../utils/createFetcherStore'
14
+
15
+ // Users may, in many situations, be developing
16
+ // without a connection to the Dashboard UI.
17
+ // This timeout allows us to return a fallback state
18
+ // instead of suspending.
19
+ const FAVORITES_FETCH_TIMEOUT = 3000
20
+
21
+ /**
22
+ * @public
23
+ */
24
+ export interface FavoriteStatusResponse {
25
+ isFavorited: boolean
26
+ }
27
+
28
+ /**
29
+ * @public
30
+ */
31
+ interface FavoriteDocumentContext extends DocumentHandle {
32
+ resourceId: string
33
+ resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
34
+ schemaName?: string
35
+ }
36
+
37
+ // Helper to create a stable key for the store
38
+ function createFavoriteKey(context: FavoriteDocumentContext): string {
39
+ return `${context.documentId}:${context.documentType}:${context.resourceId}:${context.resourceType}${
40
+ context.schemaName ? `:${context.schemaName}` : ''
41
+ }`
42
+ }
43
+
44
+ const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusResponse>({
45
+ name: 'Favorites',
46
+ getKey: (_instance: SanityInstance, context: FavoriteDocumentContext) => {
47
+ return createFavoriteKey(context)
48
+ },
49
+ fetcher: (instance: SanityInstance) => {
50
+ return (context: FavoriteDocumentContext): Observable<FavoriteStatusResponse> => {
51
+ const node = getOrCreateNode(instance, {
52
+ name: SDK_NODE_NAME,
53
+ connectTo: SDK_CHANNEL_NAME,
54
+ })
55
+
56
+ const payload = {
57
+ document: {
58
+ id: context.documentId,
59
+ type: context.documentType,
60
+ resource: {
61
+ id: context.resourceId,
62
+ type: context.resourceType,
63
+ schemaName: context.schemaName,
64
+ },
65
+ },
66
+ }
67
+
68
+ const dashboardFetch = from(
69
+ node.fetch(
70
+ // @ts-expect-error -- getOrCreateNode should be refactored to take type arguments
71
+ 'dashboard/v1/events/favorite/query',
72
+ payload,
73
+ ) as Promise<FavoriteStatusResponse>,
74
+ ).pipe(
75
+ timeout({
76
+ first: FAVORITES_FETCH_TIMEOUT,
77
+ with: () => throwError(() => new Error('Favorites service connection timeout')),
78
+ }),
79
+ map((response) => {
80
+ return {isFavorited: response.isFavorited}
81
+ }),
82
+ catchError((err) => {
83
+ // eslint-disable-next-line no-console
84
+ console.error('Favorites service connection error', err)
85
+ return of({isFavorited: false})
86
+ }),
87
+ // Share the same subscription between multiple subscribers
88
+ shareReplay(1),
89
+ )
90
+
91
+ // Clean up when all subscribers are gone
92
+ return new Observable<FavoriteStatusResponse>((subscriber) => {
93
+ const subscription = dashboardFetch.subscribe(subscriber)
94
+ return () => {
95
+ subscription.unsubscribe()
96
+ // If this was the last subscriber, clean up
97
+ if (subscription.closed) {
98
+ releaseNode(instance, SDK_NODE_NAME)
99
+ }
100
+ }
101
+ })
102
+ }
103
+ },
104
+ })
105
+
106
+ /**
107
+ * Gets a StateSource for the favorite status of a document.
108
+ * @param instance - The Sanity instance.
109
+ * @param context - The document context including ID, type, and resource information.
110
+ * @returns A StateSource emitting `{ isFavorited: boolean }`.
111
+ * @public
112
+ */
113
+ export const getFavoritesState = favorites.getState
114
+
115
+ /**
116
+ * Resolves the favorite status for a document.
117
+ * @param instance - The Sanity instance.
118
+ * @param context - The document context including ID, type, and resource information.
119
+ * @returns A Promise resolving to `{ isFavorited: boolean }`.
120
+ * @public
121
+ */
122
+ export const resolveFavoritesState = favorites.resolveState
@@ -1,8 +1,7 @@
1
- import {type SanityDocumentLike} from '@sanity/types'
2
1
  import {of} from 'rxjs'
3
2
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
3
 
5
- import {type DocumentHandle} from '../config/sanityConfig'
4
+ import {createDocumentHandle} from '../config/handles'
6
5
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
6
  import {type StateSource} from '../store/createStateSourceAction'
8
7
  import {getPreviewState} from './getPreviewState'
@@ -32,10 +31,10 @@ describe('resolvePreview', () => {
32
31
  })
33
32
 
34
33
  it('resolves a preview and returns the first emitted value with results', async () => {
35
- const docHandle: DocumentHandle<SanityDocumentLike> = {
34
+ const docHandle = createDocumentHandle({
36
35
  documentId: 'doc123',
37
36
  documentType: 'movie',
38
- }
37
+ })
39
38
 
40
39
  const result = await resolvePreview(instance, docHandle)
41
40
 
@@ -53,13 +53,13 @@ describe('subscribeToStateAndFetchBatches', () => {
53
53
  expect(getQueryState).toHaveBeenCalledTimes(1)
54
54
  expect(getQueryState).toHaveBeenCalledWith(
55
55
  instance,
56
- expect.any(String),
57
56
  expect.objectContaining({
58
57
  params: expect.objectContaining({
59
58
  __ids_71322c7a: ['doc1', 'drafts.doc1', 'doc2', 'drafts.doc2'],
60
59
  }),
61
60
  perspective: PREVIEW_PERSPECTIVE,
62
61
  tag: PREVIEW_TAG,
62
+ query: expect.any(String),
63
63
  }),
64
64
  )
65
65
 
@@ -59,7 +59,8 @@ export const subscribeToStateAndFetchBatches = ({
59
59
  const {query, params} = createPreviewQuery(ids)
60
60
  const controller = new AbortController()
61
61
  return new Observable<PreviewQueryResult[]>((observer) => {
62
- const {getCurrent, observable} = getQueryState<PreviewQueryResult[]>(instance, query, {
62
+ const {getCurrent, observable} = getQueryState<PreviewQueryResult[]>(instance, {
63
+ query,
63
64
  params,
64
65
  tag: PREVIEW_TAG,
65
66
  perspective: PREVIEW_PERSPECTIVE,
@@ -67,7 +68,8 @@ export const subscribeToStateAndFetchBatches = ({
67
68
  const source$ = defer(() => {
68
69
  if (getCurrent() === undefined) {
69
70
  return from(
70
- resolveQuery<PreviewQueryResult[]>(instance, query, {
71
+ resolveQuery<PreviewQueryResult[]>(instance, {
72
+ query,
71
73
  params,
72
74
  tag: PREVIEW_TAG,
73
75
  perspective: PREVIEW_PERSPECTIVE,
@@ -0,0 +1,35 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {compareProjectOrganization, type OrgVerificationResult} from './organizationVerification'
4
+
5
+ describe('compareProjectOrganization', () => {
6
+ const projectId = 'proj-1'
7
+ const expectedOrgId = 'org-abc'
8
+
9
+ it('should return error null when project orgId matches expected orgId', () => {
10
+ const projectOrgId = 'org-abc'
11
+ const result = compareProjectOrganization(projectId, projectOrgId, expectedOrgId)
12
+ expect(result).toEqual<OrgVerificationResult>({error: null})
13
+ })
14
+
15
+ it('should return an error message when project orgId does not match expected orgId', () => {
16
+ const projectOrgId = 'org-xyz' // Mismatch
17
+ const result = compareProjectOrganization(projectId, projectOrgId, expectedOrgId)
18
+ expect(result.error).toContain('belongs to Organization org-xyz')
19
+ expect(result.error).toContain('Dashboard has Organization org-abc selected')
20
+ })
21
+
22
+ it('should return an error message when project orgId is null', () => {
23
+ const projectOrgId = null
24
+ const result = compareProjectOrganization(projectId, projectOrgId, expectedOrgId)
25
+ expect(result.error).toContain('belongs to Organization unknown')
26
+ expect(result.error).toContain('Dashboard has Organization org-abc selected')
27
+ })
28
+
29
+ it('should return an error message when project orgId is undefined', () => {
30
+ const projectOrgId = undefined
31
+ const result = compareProjectOrganization(projectId, projectOrgId, expectedOrgId)
32
+ expect(result.error).toContain('belongs to Organization unknown')
33
+ expect(result.error).toContain('Dashboard has Organization org-abc selected')
34
+ })
35
+ })
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Error message returned by the organization verification
3
+ * @public
4
+ */
5
+ export interface OrgVerificationResult {
6
+ error: string | null
7
+ }
8
+
9
+ /**
10
+ * Compares a project's actual organization ID with the expected organization ID.
11
+ * @public
12
+ */
13
+ export function compareProjectOrganization(
14
+ projectId: string,
15
+ projectOrganizationId: string | null | undefined,
16
+ currentDashboardOrgId: string,
17
+ ): OrgVerificationResult {
18
+ if (projectOrganizationId !== currentDashboardOrgId) {
19
+ return {
20
+ error:
21
+ `Project ${projectId} belongs to Organization ${projectOrganizationId ?? 'unknown'}, ` +
22
+ `but the Dashboard has Organization ${currentDashboardOrgId} selected`,
23
+ }
24
+ }
25
+ return {error: null}
26
+ }