@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.2

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 (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2332
  29. package/dist/index.js +4171 -4332
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -1,17 +1,21 @@
1
- import {type LiveEvent, type SanityClient, type SyncTag} from '@sanity/client'
1
+ import {
2
+ ConnectionFailedError,
3
+ CorsOriginError,
4
+ DisconnectError,
5
+ type LiveEvent,
6
+ type SanityClient,
7
+ type SyncTag,
8
+ } from '@sanity/client'
2
9
  import {delay, filter, firstValueFrom, Observable, of, Subject} from 'rxjs'
3
10
  import {beforeEach, describe, expect, it, vi} from 'vitest'
4
11
 
5
12
  import {getClientState} from '../client/clientStore'
13
+ import {LIVE_EVENTS_RETRY_DELAY} from '../client/liveEvents'
6
14
  import {isCanvasResource} from '../config/sanityConfig'
7
15
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8
16
  import {type StateSource} from '../store/createStateSourceAction'
9
17
  import {getQueryState, resolveQuery} from './queryStore'
10
-
11
- vi.mock('./queryStoreConstants', async (importOriginal) => ({
12
- ...(await importOriginal<typeof import('./queryStoreConstants')>()),
13
- QUERY_STATE_CLEAR_DELAY: 10,
14
- }))
18
+ import {QUERY_STATE_CLEAR_DELAY} from './queryStoreConstants'
15
19
 
16
20
  vi.mock('../client/clientStore', () => ({
17
21
  getClientState: vi.fn(),
@@ -34,6 +38,14 @@ vi.mock('../releases/getPerspectiveState', async () => {
34
38
  }
35
39
  })
36
40
 
41
+ // With fake timers, an emission gated on a pending rxjs delay or cleanup
42
+ // timeout never arrives on its own: create the promise first, advance the
43
+ // clock, then await it.
44
+ async function advanceAndAwait<T>(promise: Promise<T>, ms = 0): Promise<T> {
45
+ await vi.advanceTimersByTimeAsync(ms)
46
+ return promise
47
+ }
48
+
37
49
  describe('queryStore', () => {
38
50
  let instance: SanityInstance
39
51
  let liveEvents: Subject<LiveEvent>
@@ -48,7 +60,8 @@ describe('queryStore', () => {
48
60
  }
49
61
 
50
62
  beforeEach(() => {
51
- instance = createSanityInstance()
63
+ vi.useFakeTimers()
64
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
52
65
 
53
66
  fetch = vi
54
67
  .fn()
@@ -76,11 +89,12 @@ describe('queryStore', () => {
76
89
  afterEach(() => {
77
90
  vi.mocked(getClientState).mockClear()
78
91
  instance.dispose()
92
+ vi.useRealTimers()
79
93
  })
80
94
 
81
95
  it('initializes query state and cleans up after unsubscribe', async () => {
82
96
  const query = '*[_type == "movie"]'
83
- const state = getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
97
+ const state = getQueryState(instance, {query})
84
98
 
85
99
  // Initially undefined before subscription
86
100
  expect(state.getCurrent()).toBeUndefined()
@@ -89,7 +103,7 @@ describe('queryStore', () => {
89
103
  const unsubscribe = state.subscribe()
90
104
 
91
105
  // Wait for data to be fetched
92
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
106
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
93
107
 
94
108
  // Verify data is present
95
109
  expect(state.getCurrent()).toEqual([
@@ -101,7 +115,7 @@ describe('queryStore', () => {
101
115
  unsubscribe()
102
116
 
103
117
  // Wait for the cleanup delay
104
- await new Promise((resolve) => setTimeout(resolve, 20))
118
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
105
119
 
106
120
  // Verify state is cleared
107
121
  expect(state.getCurrent()).toBeUndefined()
@@ -109,14 +123,14 @@ describe('queryStore', () => {
109
123
 
110
124
  it('maintains state when multiple subscribers exist', async () => {
111
125
  const query = '*[_type == "movie"]'
112
- const state = getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
126
+ const state = getQueryState(instance, {query})
113
127
 
114
128
  // Add two subscribers
115
129
  const unsubscribe1 = state.subscribe()
116
130
  const unsubscribe2 = state.subscribe()
117
131
 
118
132
  // Wait for data to be fetched
119
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
133
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
120
134
 
121
135
  // Verify data is present
122
136
  expect(state.getCurrent()).toEqual([
@@ -137,7 +151,7 @@ describe('queryStore', () => {
137
151
  unsubscribe2()
138
152
 
139
153
  // Wait for cleanup delay
140
- await new Promise((resolve) => setTimeout(resolve, 20))
154
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
141
155
 
142
156
  // Verify state is cleared after all subscribers are gone
143
157
  expect(state.getCurrent()).toBeUndefined()
@@ -146,22 +160,22 @@ describe('queryStore', () => {
146
160
  it('resolveQuery works without affecting subscriber cleanup', async () => {
147
161
  const query = '*[_type == "movie"]'
148
162
 
149
- const state = getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
163
+ const state = getQueryState(instance, {query})
150
164
 
151
165
  // Check that getQueryState starts undefined
152
166
  expect(state.getCurrent()).toBeUndefined()
153
167
 
154
- // Use resolveQuery which should not add a subscriber
155
- const result = await resolveQuery(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
168
+ // resolveQuery holds only a temporary subscriber (released after the
169
+ // clear delay once the promise settles)
170
+ const result = await advanceAndAwait(resolveQuery(instance, {query}))
156
171
  expect(result).toEqual([
157
172
  {_id: 'movie1', _type: 'movie', title: 'Movie 1'},
158
173
  {_id: 'movie2', _type: 'movie', title: 'Movie 2'},
159
174
  ])
160
175
 
161
- // Check that getQueryState starts resolved now.
162
- // Note that this behavior is important for supporting suspense.
163
- // `resolveQuery` is the only way to resolve state without adding a
164
- // subscriber that can trigger a clean up
176
+ // Check that getQueryState is resolved now. This behavior is important
177
+ // for supporting suspense: the resolved state must remain readable while
178
+ // React re-renders and attaches a lasting subscriber
165
179
  expect(state.getCurrent()).toEqual([
166
180
  {_id: 'movie1', _type: 'movie', title: 'Movie 1'},
167
181
  {_id: 'movie2', _type: 'movie', title: 'Movie 2'},
@@ -170,7 +184,7 @@ describe('queryStore', () => {
170
184
  // Subscribing and unsubscribing should nuke the state now
171
185
  const unsubscribe = state.subscribe()
172
186
  unsubscribe()
173
- await new Promise((resolve) => setTimeout(resolve, 20))
187
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
174
188
  expect(state.getCurrent()).toBeUndefined()
175
189
  })
176
190
 
@@ -179,11 +193,7 @@ describe('queryStore', () => {
179
193
  const abortController = new AbortController()
180
194
 
181
195
  // Create a promise that will reject when aborted
182
- const queryPromise = resolveQuery(instance, {
183
- query,
184
- resource: {projectId: 'p', dataset: 'd'},
185
- signal: abortController.signal,
186
- })
196
+ const queryPromise = resolveQuery(instance, {query, signal: abortController.signal})
187
197
 
188
198
  // Abort the request
189
199
  abortController.abort()
@@ -192,9 +202,14 @@ describe('queryStore', () => {
192
202
  await expect(queryPromise).rejects.toThrow('The operation was aborted.')
193
203
 
194
204
  // Verify state is cleared after abort
195
- expect(
196
- getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}}).getCurrent(),
197
- ).toBeUndefined()
205
+ expect(getQueryState(instance, {query}).getCurrent()).toBeUndefined()
206
+
207
+ // The key must be removed immediately (not after the clear delay): a new
208
+ // resolveQuery re-adds it, which only triggers a fresh fetch if the abort
209
+ // actually released the previous temporary subscriber
210
+ const callsBefore = vi.mocked(fetch).mock.calls.length
211
+ await expect(advanceAndAwait(resolveQuery(instance, {query}))).resolves.toEqual(mockData.movies)
212
+ expect(vi.mocked(fetch).mock.calls.length).toBe(callsBefore + 1)
198
213
  })
199
214
 
200
215
  it('refetches query when receiving live event with matching sync tag', async () => {
@@ -213,13 +228,10 @@ describe('queryStore', () => {
213
228
  )
214
229
 
215
230
  const query = '*[_type == "movie"]'
216
- const state = getQueryState<{_id: string; _type: string; title: string}[]>(instance, {
217
- query,
218
- resource: {projectId: 'p', dataset: 'd'},
219
- })
231
+ const state = getQueryState<{_id: string; _type: string; title: string}[]>(instance, {query})
220
232
 
221
233
  const unsubscribe = state.subscribe()
222
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
234
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
223
235
 
224
236
  // Emit live event with matching sync tag
225
237
  liveEvents.next({
@@ -231,7 +243,9 @@ describe('queryStore', () => {
231
243
  } as LiveEvent)
232
244
 
233
245
  // Wait for updated data
234
- const result = await firstValueFrom(state.observable.pipe(filter((data) => data?.length === 3)))
246
+ const result = await advanceAndAwait(
247
+ firstValueFrom(state.observable.pipe(filter((data) => data?.length === 3))),
248
+ )
235
249
 
236
250
  expect(fetch).toHaveBeenCalledTimes(2)
237
251
  expect(vi.mocked(fetch).mock.calls[1][2]?.lastLiveEventId).toBe('event1')
@@ -247,10 +261,10 @@ describe('queryStore', () => {
247
261
  )
248
262
 
249
263
  const query = '*[_type == "movie"]'
250
- const state = getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
264
+ const state = getQueryState(instance, {query})
251
265
 
252
266
  const unsubscribe = state.subscribe()
253
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
267
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
254
268
 
255
269
  // Emit event with different tag
256
270
  liveEvents.next({
@@ -261,7 +275,7 @@ describe('queryStore', () => {
261
275
  event: 'created',
262
276
  } as LiveEvent)
263
277
 
264
- await new Promise((resolve) => setTimeout(resolve, 50)) // Allow time for potential refetch
278
+ await vi.advanceTimersByTimeAsync(50) // Allow time for potential refetch
265
279
  expect(fetch).toHaveBeenCalledTimes(1)
266
280
 
267
281
  unsubscribe()
@@ -280,10 +294,10 @@ describe('queryStore', () => {
280
294
  )
281
295
 
282
296
  const query = '*[_type == "movie"]'
283
- const state = getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
297
+ const state = getQueryState(instance, {query})
284
298
 
285
299
  const unsubscribe = state.subscribe()
286
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
300
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
287
301
 
288
302
  // Emit two events with same tag
289
303
  liveEvents.next({
@@ -298,7 +312,7 @@ describe('queryStore', () => {
298
312
  tags: mockSyncTags,
299
313
  })
300
314
 
301
- await new Promise((resolve) => setTimeout(resolve, 0))
315
+ await vi.advanceTimersByTimeAsync(0)
302
316
  expect(fetch).toHaveBeenCalledTimes(3)
303
317
  expect(vi.mocked(fetch).mock.calls[1][2]?.lastLiveEventId).toBe('event1')
304
318
  expect(vi.mocked(fetch).mock.calls[2][2]?.lastLiveEventId).toBe('event2')
@@ -317,7 +331,7 @@ describe('queryStore', () => {
317
331
  )
318
332
 
319
333
  const query = '*[_type == "movie"]'
320
- const state = getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
334
+ const state = getQueryState(instance, {query})
321
335
  const unsubscribe = state.subscribe()
322
336
 
323
337
  // Verify error is thrown when accessing state
@@ -326,28 +340,199 @@ describe('queryStore', () => {
326
340
  unsubscribe()
327
341
  })
328
342
 
343
+ it('refetches when a query key is re-added after an error', async () => {
344
+ // First fetch fails (e.g. transient network failure)
345
+ vi.mocked(fetch).mockReturnValueOnce(
346
+ new Observable((observer) => {
347
+ observer.error(new Error('transient network failure'))
348
+ }),
349
+ )
350
+
351
+ const query = '*[_type == "movie"]'
352
+ const state1 = getQueryState(instance, {query})
353
+ const unsub1 = state1.subscribe()
354
+ expect(() => state1.getCurrent()).toThrow('transient network failure')
355
+ unsub1()
356
+
357
+ // Wait for the clear delay so the key is fully removed from state
358
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
359
+
360
+ // Retry with the same key — must trigger a fresh fetch
361
+ const state2 = getQueryState(instance, {query})
362
+ const unsub2 = state2.subscribe()
363
+
364
+ await vi.advanceTimersByTimeAsync(0)
365
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2)
366
+
367
+ const result = await advanceAndAwait(
368
+ firstValueFrom(state2.observable.pipe(filter((i) => i !== undefined))),
369
+ )
370
+ expect(result).toEqual(mockData.movies)
371
+ unsub2()
372
+ })
373
+
374
+ it('releases an errored key created by resolveQuery so a retry can refetch', async () => {
375
+ vi.mocked(fetch).mockReturnValueOnce(
376
+ new Observable((observer) => {
377
+ observer.error(new Error('network down'))
378
+ }),
379
+ )
380
+
381
+ const query = '*[_type == "movie"]'
382
+ // This is how React drives a suspended query: resolveQuery creates the
383
+ // key without any subscriber (the component never commits when it throws)
384
+ await expect(resolveQuery(instance, {query})).rejects.toThrow('network down')
385
+
386
+ // While the errored key exists, the error surfaces to error boundaries
387
+ const state = getQueryState(instance, {query})
388
+ expect(() => state.getCurrent()).toThrow('network down')
389
+
390
+ // After the clear delay, the errored key must be released — otherwise it
391
+ // has no subscribers, nothing ever removes it, and every future mount
392
+ // rethrows the stored error without ever fetching
393
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
394
+ expect(state.getCurrent()).toBeUndefined()
395
+
396
+ // A retry now creates a fresh key and fetches
397
+ await expect(advanceAndAwait(resolveQuery(instance, {query}))).resolves.toEqual(mockData.movies)
398
+ })
399
+
400
+ it('stops live updates without retrying or erroring on a 4xx connection rejection', async () => {
401
+ const query = '*[_type == "movie"]'
402
+ const state = getQueryState(instance, {query})
403
+ const unsub = state.subscribe()
404
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
405
+
406
+ const clientState = vi.mocked(getClientState).mock.results[0]
407
+ ?.value as StateSource<SanityClient>
408
+ const client = await firstValueFrom(clientState.observable)
409
+ const eventsMock = vi.mocked(client.live.events)
410
+ const callsBefore = eventsMock.mock.calls.length
411
+
412
+ // The server rejected the connection with a 401 (e.g. expired token). The
413
+ // client surfaces this as a fatal ConnectionFailedError with the status —
414
+ // retrying would reconnect once per second forever against a server that
415
+ // keeps rejecting
416
+ liveEvents.error(new ConnectionFailedError('EventSource connection failed', {status: 401}))
417
+ await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
418
+
419
+ expect(() => state.getCurrent()).not.toThrow()
420
+ expect(eventsMock.mock.calls.length).toBe(callsBefore)
421
+ unsub()
422
+ })
423
+
424
+ it('retries a connection failure without a status (transient network failure)', async () => {
425
+ const query = '*[_type == "movie"]'
426
+ const state = getQueryState(instance, {query})
427
+ const unsub = state.subscribe()
428
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
429
+
430
+ const clientState = vi.mocked(getClientState).mock.results[0]
431
+ ?.value as StateSource<SanityClient>
432
+ const client = await firstValueFrom(clientState.observable)
433
+ const eventsMock = vi.mocked(client.live.events)
434
+ const callsBefore = eventsMock.mock.calls.length
435
+
436
+ // No status means the failure could be transient (native EventSource
437
+ // exposes no status) — reconnecting is correct here
438
+ liveEvents.error(new ConnectionFailedError('EventSource connection failed'))
439
+ await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 2)
440
+
441
+ expect(() => state.getCurrent()).not.toThrow()
442
+ expect(eventsMock.mock.calls.length).toBeGreaterThan(callsBefore)
443
+ unsub()
444
+ })
445
+
446
+ it('surfaces CORS errors on the live connection as a store-wide error', async () => {
447
+ const query = '*[_type == "movie"]'
448
+ const state = getQueryState(instance, {query})
449
+ const unsub = state.subscribe()
450
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
451
+
452
+ liveEvents.error(new CorsOriginError({projectId: 'test'}))
453
+ await vi.advanceTimersByTimeAsync(0)
454
+
455
+ // the swallowed CORS error is recorded as store state so the query
456
+ // selector rethrows it (handled by the Cors Error component)
457
+ expect(() => state.getCurrent()).toThrow(CorsOriginError)
458
+ unsub()
459
+ })
460
+
461
+ it('stops live updates without retrying or erroring on DisconnectError', async () => {
462
+ const query = '*[_type == "movie"]'
463
+ const state = getQueryState(instance, {query})
464
+ const unsub = state.subscribe()
465
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
466
+
467
+ // Grab the live events factory from the client the store is subscribed to
468
+ const clientState = vi.mocked(getClientState).mock.results[0]
469
+ ?.value as StateSource<SanityClient>
470
+ const client = await firstValueFrom(clientState.observable)
471
+ const eventsMock = vi.mocked(client.live.events)
472
+ const callsBefore = eventsMock.mock.calls.length
473
+
474
+ // The server instructed the client to stop reconnecting
475
+ liveEvents.error(new DisconnectError('Server disconnected client'))
476
+ // Advance past several retry delays
477
+ await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
478
+
479
+ // No store-wide error, and no reconnect attempts (a retry would call
480
+ // live.events again)
481
+ expect(() => state.getCurrent()).not.toThrow()
482
+ expect(eventsMock.mock.calls.length).toBe(callsBefore)
483
+ unsub()
484
+ })
485
+
486
+ it('keeps queries working after the live events connection errors', async () => {
487
+ const query = '*[_type == "movie"]'
488
+ const state = getQueryState(instance, {query})
489
+ const unsub = state.subscribe()
490
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
491
+
492
+ // The live connection drops (e.g. network goes offline)
493
+ liveEvents.error(new Error('live connection lost'))
494
+ await vi.advanceTimersByTimeAsync(0)
495
+
496
+ // Existing query state must remain readable — a lost live connection must
497
+ // not poison the whole store
498
+ expect(() => state.getCurrent()).not.toThrow()
499
+ expect(state.getCurrent()).toEqual(mockData.movies)
500
+ unsub()
501
+
502
+ // Wait for the clear delay so the key is fully removed, then re-add it —
503
+ // fetching must still work
504
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
505
+ const state2 = getQueryState(instance, {query})
506
+ const unsub2 = state2.subscribe()
507
+ const result = await advanceAndAwait(
508
+ firstValueFrom(state2.observable.pipe(filter((i) => i !== undefined))),
509
+ )
510
+ expect(result).toEqual(mockData.movies)
511
+ unsub2()
512
+ })
513
+
329
514
  it('delays query state removal after unsubscribe', async () => {
330
515
  const query = '*[_type == "movie"]'
331
- const state = getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
516
+ const state = getQueryState(instance, {query})
332
517
  const unsubscribe = state.subscribe()
333
518
 
334
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
519
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
335
520
 
336
521
  unsubscribe()
337
522
  // Immediately after unsubscription, state should still be present due to delay
338
523
  expect(state.getCurrent()).not.toBeUndefined()
339
524
 
340
525
  // Wait for the cleanup delay and then state should be removed
341
- await new Promise((resolve) => setTimeout(resolve, 20))
526
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
342
527
  expect(state.getCurrent()).toBeUndefined()
343
528
  })
344
529
 
345
530
  it('preserves query state if a new subscriber subscribes before cleanup delay', async () => {
346
531
  const query = '*[_type == "movie"]'
347
- const state = getQueryState(instance, {query, resource: {projectId: 'p', dataset: 'd'}})
532
+ const state = getQueryState(instance, {query})
348
533
  const unsubscribe1 = state.subscribe()
349
534
 
350
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
535
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
351
536
  expect(state.getCurrent()).toEqual([
352
537
  {_id: 'movie1', _type: 'movie', title: 'Movie 1'},
353
538
  {_id: 'movie2', _type: 'movie', title: 'Movie 2'},
@@ -355,13 +540,13 @@ describe('queryStore', () => {
355
540
 
356
541
  unsubscribe1()
357
542
  // Wait less than the cleanup delay
358
- await new Promise((resolve) => setTimeout(resolve, 5))
543
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY / 2)
359
544
 
360
545
  // Subscribe again before cleanup occurs
361
546
  const unsubscribe2 = state.subscribe()
362
547
 
363
548
  // Wait for cleanup delay to pass
364
- await new Promise((resolve) => setTimeout(resolve, 20))
549
+ await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
365
550
 
366
551
  // Since a subscriber now exists, state should still be present
367
552
  expect(state.getCurrent()).toEqual([
@@ -382,30 +567,30 @@ describe('queryStore', () => {
382
567
  }) as SanityClient['observable']['fetch'])
383
568
 
384
569
  const draftsInstance = createSanityInstance({
570
+ projectId: 'test',
571
+ dataset: 'test',
385
572
  perspective: 'drafts',
386
573
  })
387
574
  const publishedInstance = createSanityInstance({
575
+ projectId: 'test',
576
+ dataset: 'test',
388
577
  perspective: 'published',
389
578
  })
390
579
 
391
580
  // Same query/options, different implicit perspectives via instance.config
392
- const sDrafts = getQueryState<{_id: string}[]>(draftsInstance, {
393
- query: '*[_type == "movie"]',
394
- resource: {projectId: 'p', dataset: 'd'},
395
- })
581
+ const sDrafts = getQueryState<{_id: string}[]>(draftsInstance, {query: '*[_type == "movie"]'})
396
582
  const sPublished = getQueryState<{_id: string}[]>(publishedInstance, {
397
583
  query: '*[_type == "movie"]',
398
- resource: {projectId: 'p', dataset: 'd'},
399
584
  })
400
585
 
401
586
  const unsubDrafts = sDrafts.subscribe()
402
587
  const unsubPublished = sPublished.subscribe()
403
588
 
404
- const draftsResult = await firstValueFrom(
405
- sDrafts.observable.pipe(filter((i) => i !== undefined)),
589
+ const draftsResult = await advanceAndAwait(
590
+ firstValueFrom(sDrafts.observable.pipe(filter((i) => i !== undefined))),
406
591
  )
407
- const publishedResult = await firstValueFrom(
408
- sPublished.observable.pipe(filter((i) => i !== undefined)),
592
+ const publishedResult = await advanceAndAwait(
593
+ firstValueFrom(sPublished.observable.pipe(filter((i) => i !== undefined))),
409
594
  )
410
595
 
411
596
  expect(draftsResult).toEqual([{_id: 'drafts'}])
@@ -427,27 +612,25 @@ describe('queryStore', () => {
427
612
  >
428
613
  }) as SanityClient['observable']['fetch'])
429
614
 
430
- const base = createSanityInstance()
615
+ const base = createSanityInstance({projectId: 'test', dataset: 'test'})
431
616
 
432
617
  const sDrafts = getQueryState<{_id: string}[]>(base, {
433
618
  query: '*[_type == "movie"]',
434
619
  perspective: 'drafts',
435
- resource: {projectId: 'p', dataset: 'd'},
436
620
  })
437
621
  const sPublished = getQueryState<{_id: string}[]>(base, {
438
622
  query: '*[_type == "movie"]',
439
623
  perspective: 'published',
440
- resource: {projectId: 'p', dataset: 'd'},
441
624
  })
442
625
 
443
626
  const unsubDrafts = sDrafts.subscribe()
444
627
  const unsubPublished = sPublished.subscribe()
445
628
 
446
- const draftsResult = await firstValueFrom(
447
- sDrafts.observable.pipe(filter((i) => i !== undefined)),
629
+ const draftsResult = await advanceAndAwait(
630
+ firstValueFrom(sDrafts.observable.pipe(filter((i) => i !== undefined))),
448
631
  )
449
- const publishedResult = await firstValueFrom(
450
- sPublished.observable.pipe(filter((i) => i !== undefined)),
632
+ const publishedResult = await advanceAndAwait(
633
+ firstValueFrom(sPublished.observable.pipe(filter((i) => i !== undefined))),
451
634
  )
452
635
 
453
636
  expect(draftsResult).toEqual([{_id: 'drafts'}])
@@ -461,15 +644,15 @@ describe('queryStore', () => {
461
644
 
462
645
  it('uses resource from params when passed in query options (listenForNewSubscribersAndFetch)', async () => {
463
646
  const query = '*[_type == "movie"]'
464
- const mediaLibraryResource = {mediaLibraryId: 'ml123'}
647
+ const mediaLibrarySource = {mediaLibraryId: 'ml123'}
465
648
 
466
- const state = getQueryState(instance, {query, resource: mediaLibraryResource})
649
+ const state = getQueryState(instance, {query, resource: mediaLibrarySource})
467
650
  const unsubscribe = state.subscribe()
468
651
 
469
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
652
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
470
653
 
471
- // Verify getClientState was called with the source from params in listenForNewSubscribersAndFetch
472
- // This call includes projectId, dataset, and source
654
+ // Verify getClientState was called with the resource from params in listenForNewSubscribersAndFetch
655
+ // This call includes projectId, dataset, and resource
473
656
  expect(getClientState).toHaveBeenCalledWith(
474
657
  instance,
475
658
  expect.objectContaining({
@@ -484,16 +667,16 @@ describe('queryStore', () => {
484
667
 
485
668
  it('uses resource from store context key when not a dataset resource (listenToLiveClientAndSetLastLiveEventIds)', async () => {
486
669
  const query = '*[_type == "movie"]'
487
- const canvasResource = {canvasId: 'canvas456'}
670
+ const canvasSource = {canvasId: 'canvas456'}
488
671
 
489
- const state = getQueryState(instance, {query, resource: canvasResource})
672
+ const state = getQueryState(instance, {query, resource: canvasSource})
490
673
  const unsubscribe = state.subscribe()
491
674
 
492
- await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
675
+ await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
493
676
 
494
- // Verify getClientState was called with the canvas source for live events
495
- // The source is extracted from the store key and passed when it's not a dataset source
496
- // This call only has apiVersion and source (no projectId/dataset)
677
+ // Verify getClientState was called with the canvas resource for live events
678
+ // The resource is extracted from the store key and passed when it's not a dataset resource
679
+ // This call only has apiVersion and resource (no projectId/dataset)
497
680
  const calls = vi.mocked(getClientState).mock.calls
498
681
  const liveClientCall = calls.find(
499
682
  ([_instance, options]) =>
@@ -503,51 +686,4 @@ describe('queryStore', () => {
503
686
 
504
687
  unsubscribe()
505
688
  })
506
-
507
- it('uses bound resource when a shared store receives a query with the same explicit resource', async () => {
508
- // Regression test: when a store for source B is first created by an
509
- // instance (passing source B explicitly), subsequent queries added to
510
- // that store by a different instance (also passing source B explicitly)
511
- // must still use resource B for client creation.
512
- const projectBSource = {projectId: 'project-b', dataset: 'production'}
513
-
514
- const rootInstance = createSanityInstance()
515
-
516
- // 1. Root instance queries with an explicit source for project B.
517
- // This creates the QueryStore:project-b.production store, whose
518
- // initialize() captures rootInstance in its closure.
519
- const stateWithResource = getQueryState(rootInstance, {
520
- query: '*[_type == "author"]',
521
- resource: projectBSource,
522
- })
523
- const unsub1 = stateWithResource.subscribe()
524
- await firstValueFrom(stateWithResource.observable.pipe(filter((i) => i !== undefined)))
525
-
526
- vi.mocked(getClientState).mockClear()
527
-
528
- // 2. A second instance queries the SAME store (same composite key)
529
- // with the same explicit resource.
530
- const secondInstance = createSanityInstance()
531
- const stateWithSameResource = getQueryState(secondInstance, {
532
- query: '*[_type == "movie"]',
533
- resource: projectBSource,
534
- })
535
- const unsub2 = stateWithSameResource.subscribe()
536
- await firstValueFrom(stateWithSameResource.observable.pipe(filter((i) => i !== undefined)))
537
-
538
- // The listener should create a client using the bound source (project B).
539
- const fetchCalls = vi.mocked(getClientState).mock.calls
540
- const correctCall = fetchCalls.find(
541
- ([, options]) =>
542
- options.resource &&
543
- 'projectId' in options.resource &&
544
- options.resource.projectId === 'project-b',
545
- )
546
- expect(correctCall).toBeDefined()
547
-
548
- unsub1()
549
- unsub2()
550
- secondInstance.dispose()
551
- rootInstance.dispose()
552
- })
553
689
  })