@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
@@ -0,0 +1,192 @@
1
+ import {filter, firstValueFrom, of, Subject, take} from 'rxjs'
2
+ import {describe, expect, it, vi} from 'vitest'
3
+
4
+ import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig'
5
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
6
+ import {listenQuery as mockListenQuery} from '../utils/listenQuery'
7
+ import {getPerspectiveState} from './getPerspectiveState'
8
+ import {type ReleaseDocument} from './releasesStore'
9
+
10
+ vi.mock('../utils/listenQuery', () => ({
11
+ listenQuery: vi.fn(),
12
+ }))
13
+
14
+ vi.mock('../client/clientStore', () => ({
15
+ getClientState: vi.fn(() => ({
16
+ observable: of({}),
17
+ getCurrent: vi.fn(),
18
+ subscribe: vi.fn(),
19
+ })),
20
+ }))
21
+
22
+ describe('getPerspectiveState', () => {
23
+ let instance: SanityInstance
24
+ let mockReleasesQuerySubject: Subject<ReleaseDocument[]>
25
+
26
+ const release1 = {
27
+ _id: 'release-1',
28
+ _type: 'sanity.release',
29
+ name: 'release1',
30
+ metadata: {title: 'Release 1', releaseType: 'asap'},
31
+ status: 'active',
32
+ } as unknown as ReleaseDocument
33
+ const release2: ReleaseDocument = {
34
+ ...release1,
35
+ _id: 'release-2',
36
+ _rev: 'rev2',
37
+ name: 'release2',
38
+ metadata: {title: 'Release 2', releaseType: 'asap'},
39
+ }
40
+
41
+ const activeReleases = [release1, release2]
42
+
43
+ beforeEach(() => {
44
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
45
+
46
+ mockReleasesQuerySubject = new Subject<ReleaseDocument[]>()
47
+ vi.mocked(mockListenQuery).mockReturnValue(mockReleasesQuerySubject.asObservable())
48
+ })
49
+
50
+ afterEach(() => {
51
+ instance.dispose()
52
+ vi.clearAllMocks()
53
+ })
54
+
55
+ it('should return default perspective if no options or instance perspective is provided', async () => {
56
+ const state = getPerspectiveState(instance)
57
+ mockReleasesQuerySubject.next([])
58
+ const perspective = await firstValueFrom(state.observable)
59
+ expect(perspective).toBe('drafts')
60
+ })
61
+
62
+ it('should return instance perspective if provided and no options perspective', async () => {
63
+ instance.config.perspective = 'published'
64
+ const state = getPerspectiveState(instance)
65
+ mockReleasesQuerySubject.next([])
66
+ const perspective = await firstValueFrom(state.observable)
67
+ expect(perspective).toBe('published')
68
+ })
69
+
70
+ it('should return options perspective if provided', async () => {
71
+ const options: PerspectiveHandle = {perspective: 'raw'}
72
+ const state = getPerspectiveState(instance, options)
73
+ mockReleasesQuerySubject.next([])
74
+ const perspective = await firstValueFrom(state.observable)
75
+ expect(perspective).toBe('raw')
76
+ })
77
+
78
+ it('should return undefined if release perspective is requested but no active releases', async () => {
79
+ const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
80
+ const state = getPerspectiveState(instance, options)
81
+ mockReleasesQuerySubject.next([])
82
+ const perspective = await firstValueFrom(state.observable)
83
+ expect(perspective).toBeUndefined()
84
+ })
85
+
86
+ it('should calculate perspective based on active releases and releaseName', async () => {
87
+ const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
88
+ const state = getPerspectiveState(instance, options)
89
+ mockReleasesQuerySubject.next(activeReleases)
90
+
91
+ const perspective = await firstValueFrom(
92
+ state.observable.pipe(
93
+ filter((p): p is NonNullable<typeof p> => p !== undefined),
94
+ take(1),
95
+ ),
96
+ )
97
+ expect(perspective).toEqual(['drafts', 'release1'])
98
+ })
99
+
100
+ it('should calculate perspective including multiple releases up to the specified releaseName', async () => {
101
+ const options: PerspectiveHandle = {perspective: {releaseName: 'release2'}}
102
+ const state = getPerspectiveState(instance, options)
103
+ mockReleasesQuerySubject.next(activeReleases)
104
+ const perspective = await firstValueFrom(
105
+ state.observable.pipe(
106
+ filter((p): p is NonNullable<typeof p> => p !== undefined),
107
+ take(1),
108
+ ),
109
+ )
110
+ expect(perspective).toEqual(['drafts', 'release1', 'release2'])
111
+ })
112
+
113
+ it('should filter excluded perspectives', async () => {
114
+ const perspectiveConfig: ReleasePerspective = {
115
+ releaseName: 'release2',
116
+ excludedPerspectives: ['drafts', 'release1'],
117
+ }
118
+ const options: PerspectiveHandle = {perspective: perspectiveConfig}
119
+ const state = getPerspectiveState(instance, options)
120
+ mockReleasesQuerySubject.next(activeReleases)
121
+ const perspective = await firstValueFrom(
122
+ state.observable.pipe(
123
+ filter((p): p is NonNullable<typeof p> => p !== undefined),
124
+ take(1),
125
+ ),
126
+ )
127
+ expect(perspective).toEqual(['release2'])
128
+ })
129
+
130
+ it('should throw if the specified releaseName is not found in active releases', async () => {
131
+ const options: PerspectiveHandle = {perspective: {releaseName: 'nonexistent'}}
132
+ const state = getPerspectiveState(instance, options)
133
+ mockReleasesQuerySubject.next(activeReleases)
134
+
135
+ await expect(
136
+ firstValueFrom(
137
+ state.observable.pipe(
138
+ filter((p): p is NonNullable<typeof p> => p !== undefined),
139
+ take(1),
140
+ ),
141
+ ),
142
+ ).rejects.toThrow('Release "nonexistent" not found in active releases')
143
+ })
144
+
145
+ it('should reuse the same options object for identical inputs (cache test)', async () => {
146
+ const options1: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
147
+ const options2: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
148
+
149
+ const state1 = getPerspectiveState(instance, options1)
150
+ mockReleasesQuerySubject.next(activeReleases)
151
+ await firstValueFrom(
152
+ state1.observable.pipe(
153
+ filter((p): p is NonNullable<typeof p> => p !== undefined),
154
+ take(1),
155
+ ),
156
+ )
157
+
158
+ const state2 = getPerspectiveState(instance, options2)
159
+ const perspective2 = state2.getCurrent()
160
+
161
+ expect(perspective2).toEqual(['drafts', 'release1'])
162
+ })
163
+
164
+ it('should handle changes in activeReleases (cache test)', async () => {
165
+ const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
166
+
167
+ const state1 = getPerspectiveState(instance, options)
168
+ mockReleasesQuerySubject.next(activeReleases)
169
+ const perspective1 = await firstValueFrom(
170
+ state1.observable.pipe(
171
+ filter((p): p is NonNullable<typeof p> => p !== undefined),
172
+ take(1),
173
+ ),
174
+ )
175
+ expect(perspective1).toEqual(['drafts', 'release1'])
176
+
177
+ const updatedActiveReleases = [release1]
178
+ mockReleasesQuerySubject.next(updatedActiveReleases)
179
+
180
+ const perspectiveAfterUpdate = await firstValueFrom(
181
+ state1.observable.pipe(
182
+ filter((p): p is NonNullable<typeof p> => p !== undefined),
183
+ take(1),
184
+ ),
185
+ )
186
+ expect(perspectiveAfterUpdate).toEqual(['drafts', 'release1'])
187
+
188
+ const state2 = getPerspectiveState(instance, options)
189
+ const perspectiveNewCall = state2.getCurrent()
190
+ expect(perspectiveNewCall).toEqual(['drafts', 'release1'])
191
+ })
192
+ })
@@ -0,0 +1,93 @@
1
+ import {createSelector} from 'reselect'
2
+
3
+ import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig'
4
+ import {bindActionByDataset} from '../store/createActionBinder'
5
+ import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction'
6
+ import {releasesStore, type ReleasesStoreState} from './releasesStore'
7
+
8
+ function isReleasePerspective(
9
+ perspective: PerspectiveHandle['perspective'],
10
+ ): perspective is ReleasePerspective {
11
+ return typeof perspective === 'object' && perspective !== null && 'releaseName' in perspective
12
+ }
13
+
14
+ const DEFAULT_PERSPECTIVE = 'drafts'
15
+
16
+ // Cache for options
17
+ const optionsCache = new Map<string, Map<string, PerspectiveHandle>>()
18
+
19
+ const selectInstancePerspective = (context: SelectorContext<ReleasesStoreState>) =>
20
+ context.instance.config.perspective
21
+ const selectActiveReleases = (context: SelectorContext<ReleasesStoreState>) =>
22
+ context.state.activeReleases
23
+ const selectOptions = (
24
+ _context: SelectorContext<ReleasesStoreState>,
25
+ options?: PerspectiveHandle,
26
+ ) => options
27
+
28
+ const memoizedOptionsSelector = createSelector(
29
+ [selectActiveReleases, selectOptions],
30
+ (activeReleases, options) => {
31
+ if (!options || !activeReleases) return options
32
+
33
+ // Use release document IDs as the cache key
34
+ const releaseIds = activeReleases.map((release) => release._id).join(',')
35
+ let nestedCache = optionsCache.get(releaseIds)
36
+ if (!nestedCache) {
37
+ nestedCache = new Map<string, PerspectiveHandle>()
38
+ optionsCache.set(releaseIds, nestedCache)
39
+ }
40
+
41
+ const optionsKey = JSON.stringify(options)
42
+ let cachedOptions = nestedCache.get(optionsKey)
43
+
44
+ if (!cachedOptions) {
45
+ cachedOptions = options
46
+ nestedCache.set(optionsKey, cachedOptions)
47
+ }
48
+ return cachedOptions
49
+ },
50
+ )
51
+
52
+ /**
53
+ * Provides a subscribable state source for a "perspective" for the Sanity client,
54
+ * which is used to fetch documents as though certain Content Releases are active.
55
+ *
56
+ * @param instance - The Sanity instance to get the perspective for
57
+ * @param options - The options for the perspective -- usually a release name
58
+ *
59
+ * @returns A subscribable perspective value, usually a list of applicable release names,
60
+ * or a single release name / default perspective (such as 'drafts').
61
+ *
62
+ * @public
63
+ */
64
+ export const getPerspectiveState = bindActionByDataset(
65
+ releasesStore,
66
+ createStateSourceAction({
67
+ selector: createSelector(
68
+ [selectInstancePerspective, selectActiveReleases, memoizedOptionsSelector],
69
+ (instancePerspective, activeReleases, memoizedOptions) => {
70
+ const perspective =
71
+ memoizedOptions?.perspective ?? instancePerspective ?? DEFAULT_PERSPECTIVE
72
+
73
+ if (!isReleasePerspective(perspective)) return perspective
74
+
75
+ // if there are no active releases we can't compute the release perspective
76
+ if (!activeReleases || activeReleases.length === 0) return undefined
77
+
78
+ const releaseNames = activeReleases.map((release) => release.name)
79
+ const index = releaseNames.findIndex((name) => name === perspective.releaseName)
80
+
81
+ if (index < 0) {
82
+ throw new Error(`Release "${perspective.releaseName}" not found in active releases`)
83
+ }
84
+
85
+ const filteredReleases = releaseNames.slice(0, index + 1) // Include the release itself
86
+
87
+ return ['drafts', ...filteredReleases].filter(
88
+ (name) => !perspective.excludedPerspectives?.includes(name),
89
+ )
90
+ },
91
+ ),
92
+ }),
93
+ )
@@ -0,0 +1,170 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {of, Subject} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {getClientState} from '../client/clientStore'
6
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
+ import {type StateSource} from '../store/createStateSourceAction'
8
+ import {listenQuery} from '../utils/listenQuery'
9
+ import {getActiveReleasesState, type ReleaseDocument} from './releasesStore'
10
+
11
+ // Mock dependencies
12
+ vi.mock('../client/clientStore', () => ({
13
+ getClientState: vi.fn(),
14
+ }))
15
+ vi.mock('../utils/listenQuery', () => ({
16
+ listenQuery: vi.fn(),
17
+ }))
18
+
19
+ // Mock console.error to prevent test runner noise and allow verification
20
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
21
+
22
+ describe('releasesStore', () => {
23
+ let instance: SanityInstance
24
+ const mockClient = {} as SanityClient
25
+
26
+ beforeEach(() => {
27
+ vi.resetAllMocks()
28
+ consoleErrorSpy.mockClear()
29
+
30
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
31
+
32
+ vi.mocked(getClientState).mockReturnValue({
33
+ observable: of(mockClient),
34
+ } as StateSource<SanityClient>)
35
+ })
36
+
37
+ afterEach(() => {
38
+ instance.dispose()
39
+ })
40
+
41
+ it('should set active releases state when listenQuery succeeds', async () => {
42
+ // note that the order of the releases is important here -- they get sorted
43
+ const mockReleases: ReleaseDocument[] = [
44
+ {
45
+ _id: 'r2',
46
+ _type: 'system.release',
47
+ name: 'Release 2',
48
+ metadata: {title: 'R2', releaseType: 'scheduled'},
49
+ } as ReleaseDocument,
50
+ {
51
+ _id: 'r1',
52
+ _type: 'system.release',
53
+ name: 'Release 1',
54
+ metadata: {title: 'R1', releaseType: 'asap'},
55
+ } as ReleaseDocument,
56
+ ]
57
+
58
+ vi.mocked(listenQuery).mockReturnValue(of(mockReleases))
59
+
60
+ const state = getActiveReleasesState(instance)
61
+
62
+ await new Promise((resolve) => setTimeout(resolve, 0))
63
+
64
+ expect(state.getCurrent()).toEqual(mockReleases)
65
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
66
+ })
67
+
68
+ it('should update active releases state when listenQuery emits new data', async () => {
69
+ const releasesSubject = new Subject<ReleaseDocument[]>()
70
+ vi.mocked(listenQuery).mockReturnValue(releasesSubject.asObservable())
71
+
72
+ const state = getActiveReleasesState(instance)
73
+
74
+ // Initial state should be default
75
+ expect(state.getCurrent()).toBeUndefined() // Default initial state
76
+
77
+ // Emit initial data
78
+ const initialReleases: ReleaseDocument[] = [
79
+ {
80
+ _id: 'r1',
81
+ _type: 'system.release',
82
+ name: 'Initial Release 1',
83
+ metadata: {title: 'IR1', releaseType: 'asap'},
84
+ } as ReleaseDocument,
85
+ ]
86
+ releasesSubject.next(initialReleases)
87
+ await new Promise((resolve) => setTimeout(resolve, 0))
88
+
89
+ expect(state.getCurrent()).toEqual(initialReleases)
90
+
91
+ const updatedReleases: ReleaseDocument[] = [
92
+ {
93
+ _id: 'r2',
94
+ _type: 'system.release',
95
+ name: 'New Release 2',
96
+ metadata: {title: 'NR2', releaseType: 'scheduled'},
97
+ } as ReleaseDocument,
98
+ {
99
+ _id: 'r1',
100
+ _type: 'system.release',
101
+ name: 'Updated Release 1',
102
+ metadata: {title: 'UR1', releaseType: 'asap'},
103
+ } as ReleaseDocument,
104
+ ]
105
+ releasesSubject.next(updatedReleases)
106
+ await new Promise((resolve) => setTimeout(resolve, 0))
107
+
108
+ expect(state.getCurrent()).toEqual(updatedReleases)
109
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
110
+ })
111
+
112
+ it('should handle empty array from listenQuery', async () => {
113
+ // Configure listenQuery to return an empty array
114
+ vi.mocked(listenQuery).mockReturnValue(of([]))
115
+
116
+ const state = getActiveReleasesState(instance)
117
+
118
+ await new Promise((resolve) => setTimeout(resolve, 0))
119
+
120
+ expect(state.getCurrent()).toEqual([]) // Should be set to empty array
121
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
122
+ })
123
+
124
+ it('should handle null/undefined from listenQuery by defaulting to empty array', async () => {
125
+ // Test null case
126
+ vi.mocked(listenQuery).mockReturnValue(of(null))
127
+ const state = getActiveReleasesState(instance)
128
+ await new Promise((resolve) => setTimeout(resolve, 0))
129
+ expect(state.getCurrent()).toEqual([])
130
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
131
+
132
+ // Test undefined case
133
+ vi.mocked(listenQuery).mockReturnValue(of(undefined))
134
+ await new Promise((resolve) => setTimeout(resolve, 0))
135
+ expect(state.getCurrent()).toEqual([])
136
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
137
+ })
138
+
139
+ it('should handle errors from listenQuery by retrying and eventually setting error state', async () => {
140
+ vi.useFakeTimers()
141
+ const error = new Error('Query failed')
142
+ const subject = new Subject<ReleaseDocument[]>()
143
+ vi.mocked(listenQuery).mockReturnValue(subject.asObservable())
144
+
145
+ // initialize the store
146
+ const state = getActiveReleasesState(instance)
147
+
148
+ // Error the subject
149
+ subject.error(error)
150
+
151
+ // Advance enough to cover the retry attempts (exponential backoff: 1s, 2s, 4s)
152
+ for (let i = 0; i < 3; i++) {
153
+ const delay = Math.pow(2, i) * 1000
154
+ await vi.advanceTimersByTimeAsync(delay)
155
+ }
156
+
157
+ // Verify error was logged at least once during retries
158
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
159
+ '[releases] Error in subscription:',
160
+ error,
161
+ 'Retry count:',
162
+ expect.any(Number),
163
+ )
164
+
165
+ // not sure how to test state.setError()
166
+ expect(state.getCurrent()).toEqual(undefined)
167
+
168
+ vi.useRealTimers()
169
+ })
170
+ })
@@ -0,0 +1,89 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {type SanityDocument} from '@sanity/types'
3
+ import {catchError, EMPTY, retry, switchMap, timer} from 'rxjs'
4
+
5
+ import {getClientState} from '../client/clientStore'
6
+ import {bindActionByDataset} from '../store/createActionBinder'
7
+ import {createStateSourceAction} from '../store/createStateSourceAction'
8
+ import {defineStore, type StoreContext} from '../store/defineStore'
9
+ import {listenQuery} from '../utils/listenQuery'
10
+ import {sortReleases} from './utils/sortReleases'
11
+
12
+ /**
13
+ * Represents a document in a Sanity dataset that represents release options.
14
+ * @internal
15
+ */
16
+ export type ReleaseDocument = SanityDocument & {
17
+ name: string
18
+ publishAt?: string
19
+ metadata: {
20
+ title: string
21
+ releaseType: 'asap' | 'scheduled' | 'undecided'
22
+ intendedPublishAt?: string
23
+ }
24
+ }
25
+
26
+ export interface ReleasesStoreState {
27
+ activeReleases?: ReleaseDocument[]
28
+ error?: unknown
29
+ }
30
+
31
+ export const releasesStore = defineStore<ReleasesStoreState>({
32
+ name: 'Releases',
33
+ getInitialState: (): ReleasesStoreState => ({
34
+ activeReleases: undefined,
35
+ }),
36
+ initialize: (context) => {
37
+ const subscription = subscribeToReleases(context)
38
+ return () => subscription.unsubscribe()
39
+ },
40
+ })
41
+
42
+ /**
43
+ * Get the active releases from the store.
44
+ * @internal
45
+ */
46
+ export const getActiveReleasesState = bindActionByDataset(
47
+ releasesStore,
48
+ createStateSourceAction({
49
+ selector: ({state}) => state.activeReleases,
50
+ }),
51
+ )
52
+
53
+ const RELEASES_QUERY = 'releases::all()[state == "active"]'
54
+ const QUERY_PARAMS = {}
55
+
56
+ const subscribeToReleases = ({instance, state}: StoreContext<ReleasesStoreState>) => {
57
+ return getClientState(instance, {
58
+ apiVersion: '2025-04-10',
59
+ perspective: 'raw',
60
+ })
61
+ .observable.pipe(
62
+ switchMap((client: SanityClient) =>
63
+ // releases are system documents, and are not supported by useQueryState
64
+ listenQuery<ReleaseDocument[]>(client, RELEASES_QUERY, QUERY_PARAMS, {
65
+ tag: 'releases-listener',
66
+ throttleTime: 1000,
67
+ transitions: ['update', 'appear', 'disappear'],
68
+ }).pipe(
69
+ retry({
70
+ count: 3,
71
+ delay: (error, retryCount) => {
72
+ // eslint-disable-next-line no-console
73
+ console.error('[releases] Error in subscription:', error, 'Retry count:', retryCount)
74
+ return timer(Math.min(1000 * Math.pow(2, retryCount), 10000))
75
+ },
76
+ }),
77
+ catchError((error) => {
78
+ state.set('setError', {error})
79
+ return EMPTY
80
+ }),
81
+ ),
82
+ ),
83
+ )
84
+ .subscribe({
85
+ next: (releases) => {
86
+ state.set('setActiveReleases', {activeReleases: sortReleases(releases ?? [])})
87
+ },
88
+ })
89
+ }