@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,3 +1,4 @@
1
+ import {filter, firstValueFrom, timeout} from 'rxjs'
1
2
  import {beforeEach, describe, expect, it, vi} from 'vitest'
2
3
 
3
4
  import {createSanityInstance, type SanityInstance} from './createSanityInstance'
@@ -14,7 +15,7 @@ describe('createStateSourceAction', () => {
14
15
  let instance: SanityInstance
15
16
 
16
17
  beforeEach(() => {
17
- instance = createSanityInstance()
18
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
18
19
  state = createStoreState({count: 0, items: [] as string[]}, {name: 'test-store'})
19
20
  })
20
21
 
@@ -179,7 +180,7 @@ describe('createStateSourceAction', () => {
179
180
 
180
181
  // New test: distinct contexts for same state with different instance
181
182
  it('should create distinct contexts for same state with different instance', () => {
182
- const secondInstance = createSanityInstance()
183
+ const secondInstance = createSanityInstance({projectId: 'test2', dataset: 'test2'})
183
184
  const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
184
185
 
185
186
  const source1 = createStateSourceAction(selector)({state, instance, key: null})
@@ -194,4 +195,65 @@ describe('createStateSourceAction', () => {
194
195
  expect(context1.instance).toBe(instance)
195
196
  expect(context2.instance).toBe(secondInstance)
196
197
  })
198
+
199
+ it('correctly observes values defined in the onSubscribe', async () => {
200
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
201
+ const source = createStateSourceAction({
202
+ selector: selector,
203
+ onSubscribe() {
204
+ state.set('update', {count: 1})
205
+ },
206
+ })({state, instance, key: null})
207
+
208
+ const value = await firstValueFrom(
209
+ source.observable.pipe(
210
+ filter((i) => i === 1),
211
+ timeout(10),
212
+ ),
213
+ )
214
+ expect(value).toBe(1)
215
+ })
216
+
217
+ it('only invokes selector once on changes', () => {
218
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
219
+ const source = createStateSourceAction({selector: selector})({state, instance, key: null})
220
+
221
+ expect(selector).toBeCalledTimes(0)
222
+
223
+ // Now it should be called once:
224
+ const sub = source.observable.subscribe()
225
+ expect(selector).toBeCalledTimes(1)
226
+
227
+ // The observable should be shared so this shouldn't invoke it first.
228
+ const sub2 = source.observable.subscribe()
229
+ expect(selector).toBeCalledTimes(1)
230
+
231
+ // Updating the value should only invoke it once:
232
+ state.set('update', {count: 1})
233
+ expect(selector).toBeCalledTimes(2)
234
+
235
+ sub2.unsubscribe()
236
+ sub.unsubscribe()
237
+
238
+ // Once everyone has unsubscribed it should be invoked again.
239
+ const sub3 = source.observable.subscribe()
240
+ expect(selector).toBeCalledTimes(3)
241
+ sub3.unsubscribe()
242
+ })
243
+
244
+ it('only subscribes once when mixing subscribe/observable', () => {
245
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
246
+ const source = createStateSourceAction({selector: selector})({state, instance, key: null})
247
+
248
+ expect(selector).toBeCalledTimes(0)
249
+
250
+ const sub = source.observable.subscribe()
251
+ expect(selector).toBeCalledTimes(1)
252
+
253
+ const sub2 = source.subscribe()
254
+ expect(selector).toBeCalledTimes(1)
255
+
256
+ sub.unsubscribe()
257
+ sub2()
258
+ })
197
259
  })
@@ -1,4 +1,4 @@
1
- import {distinctUntilChanged, map, Observable, share, skip} from 'rxjs'
1
+ import {defer, distinctUntilChanged, finalize, map, Observable, shareReplay, skip} from 'rxjs'
2
2
 
3
3
  import {type StoreAction} from './createActionBinder'
4
4
  import {type SanityInstance} from './createSanityInstance'
@@ -57,9 +57,9 @@ export interface StateSource<T> {
57
57
  * ```ts
58
58
  * // Using both state and instance in a selector (psuedo example)
59
59
  * const getUserByProjectId = createStateSourceAction(
60
- * ({ state, instance }: SelectorContext<UsersState>, options?: ResourceHandle) => {
60
+ * ({ state, instance }: SelectorContext<UsersState>, options?: ProjectHandle) => {
61
61
  * const allUsers = state.users
62
- * const projectId = options?.projectId ?? instance.config.defaultResource?.projectId
62
+ * const projectId = options?.projectId ?? instance.config.projectId
63
63
  * return allUsers.filter(user => user.projectId === projectId)
64
64
  * }
65
65
  * )
@@ -157,11 +157,11 @@ interface StateSourceOptions<TState, TParams extends unknown[], TReturn, TKey> {
157
157
  *
158
158
  * const getDocument = bindActionByResource(
159
159
  * documentStore,
160
- * createStateSourceAction(({state}, _options, documentId: string) => state.documents[documentId])
160
+ * createStateSourceAction(({state}, documentId: string) => state.documents[documentId])
161
161
  * )
162
162
  *
163
163
  * // Usage
164
- * const documentSource = getDocument(sanityInstance, {}, 'doc123')
164
+ * const documentSource = getDocument(sanityInstance, 'doc123')
165
165
  * const doc = documentSource.getCurrent()
166
166
  * const subscription = documentSource.observable.subscribe(updatedDoc => {
167
167
  * console.log('Document changed:', updatedDoc)
@@ -187,8 +187,7 @@ export function createStateSourceAction<TState, TParams extends unknown[], TRetu
187
187
  function stateSourceAction(context: StoreContext<TState, TKey>, ...params: TParams) {
188
188
  const {state, instance} = context
189
189
 
190
- const getCurrent = () => {
191
- const currentState = state.get()
190
+ const getCurrent = (currentState: TState) => {
192
191
  if (typeof currentState !== 'object' || currentState === null) {
193
192
  throw new Error(
194
193
  `Expected store state to be an object but got "${typeof currentState}" instead`,
@@ -208,53 +207,49 @@ export function createStateSourceAction<TState, TParams extends unknown[], TRetu
208
207
  return selector(selectorContext, ...params)
209
208
  }
210
209
 
211
- // Subscription manager handles both RxJS and direct subscriptions
212
- const subscribe = (onStoreChanged?: () => void) => {
213
- // Run setup handler if provided
214
- const cleanup = subscribeHandler?.(context, ...params)
210
+ // `state.observable` will emit the current value immediately and
211
+ // hence we inherit the same behavior here.
212
+ let values = state.observable.pipe(map(getCurrent), distinctUntilChanged(isEqual))
215
213
 
216
- // Set up state change subscription
217
- const subscription = state.observable
218
- .pipe(
219
- // Derive value from current state
220
- map(getCurrent),
221
- // Filter unchanged values using custom equality check
222
- distinctUntilChanged(isEqual),
223
- // Skip initial emission since we only want changes
224
- skip(1),
225
- )
226
- .subscribe({
227
- next: () => onStoreChanged?.(),
228
- // Propagate selector errors to both subscription types
229
- error: () => onStoreChanged?.(),
230
- })
214
+ if (subscribeHandler) {
215
+ values = withSubscribeHook(values, () => subscribeHandler(context, ...params))
216
+ }
217
+
218
+ // Share but replay the latest value so every subscriber gets an
219
+ // initial synchronous emission, matching `state.observable`. That keeps
220
+ // `skip(1)` in `subscribe()` aligned with "skip current snapshot" rather than
221
+ // silently eating the first real update after multicasting.
222
+ const sharedValues = values.pipe(shareReplay({bufferSize: 1, refCount: true}))
223
+
224
+ const subscribe = (onStoreChanged?: () => void) => {
225
+ const subscription = sharedValues.pipe(skip(1)).subscribe({
226
+ next: () => onStoreChanged?.(),
227
+ // Propagate selector errors to both subscription types
228
+ error: () => onStoreChanged?.(),
229
+ })
231
230
 
232
231
  return () => {
233
232
  subscription.unsubscribe()
234
- cleanup?.()
235
233
  }
236
234
  }
237
235
 
238
- // Create shared observable that handles multiple subscribers efficiently
239
- const observable = new Observable<TReturn>((observer) => {
240
- const emitCurrent = () => {
241
- try {
242
- observer.next(getCurrent())
243
- } catch (error) {
244
- observer.error(error)
245
- }
246
- }
247
- // Emit immediately on subscription
248
- emitCurrent()
249
- return subscribe(emitCurrent)
250
- }).pipe(share())
251
-
252
236
  return {
253
- getCurrent,
237
+ getCurrent: () => getCurrent(state.get()),
254
238
  subscribe,
255
- observable,
239
+ observable: sharedValues,
256
240
  }
257
241
  }
258
242
 
259
243
  return stateSourceAction
260
244
  }
245
+
246
+ /**
247
+ * Creates a new Observable which wraps an existing Observable which will invoke
248
+ * the function when a new subscriber appears.
249
+ */
250
+ function withSubscribeHook<T>(obs: Observable<T>, fn: () => void | (() => void)): Observable<T> {
251
+ return defer(() => {
252
+ const cleanup = fn()
253
+ return cleanup ? obs.pipe(finalize(() => cleanup())) : obs
254
+ })
255
+ }
@@ -13,12 +13,14 @@ describe('createStoreInstance', () => {
13
13
  randomUUID: () => 'test-uuid-1234',
14
14
  })
15
15
 
16
- instance = createSanityInstance()
16
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
17
17
  })
18
18
 
19
19
  const storeDef: StoreDefinition<{count: number}> = {
20
20
  name: 'TestStore',
21
- getInitialState: () => ({count: 0}),
21
+ getInitialState: (inst) => ({
22
+ count: inst.config.projectId === 'test' ? 0 : -1,
23
+ }),
22
24
  }
23
25
 
24
26
  it('should create store instance with initial state', () => {
@@ -0,0 +1,121 @@
1
+ import {of} from 'rxjs'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type SanityInstance} from './createSanityInstance'
5
+ import {
6
+ defineFetcher,
7
+ defineMutation,
8
+ type Fetcher,
9
+ type FetcherSnapshot,
10
+ type MutationResult,
11
+ } from './fetcherStore'
12
+
13
+ declare const instance: SanityInstance
14
+
15
+ interface Widget {
16
+ id: string
17
+ label: string
18
+ }
19
+
20
+ // No explicit generics anywhere below: `TData` is inferred from `fetch`'s
21
+ // `Observable<T>` and `TParams` from the annotated `getKey`/`fetch` params —
22
+ // the whole handle is inferred from the definition, TanStack-style.
23
+
24
+ // A list fetcher: no params, resolves to Widget[].
25
+ const widgets = defineFetcher({
26
+ name: 'test-d-widgets',
27
+ getKey: () => 'all',
28
+ fetch: () => () => of<Widget[]>([]),
29
+ })
30
+
31
+ // A detail fetcher: one string param, resolves to Widget.
32
+ const widget = defineFetcher({
33
+ name: 'test-d-widget',
34
+ getKey: (_i, id) => id,
35
+ fetch: () => (id: string) => of<Widget>({id, label: ''}),
36
+ })
37
+
38
+ test('the handle is inferred from the definition — no explicit generics', () => {
39
+ expectTypeOf(widgets).toEqualTypeOf<Fetcher<[], Widget[]>>()
40
+ expectTypeOf(widget).toEqualTypeOf<Fetcher<[id: string], Widget>>()
41
+ })
42
+
43
+ test('unannotated getKey params are inferred (not any) from the fetcher', () => {
44
+ defineFetcher({
45
+ name: 'test-d-getkey-infer',
46
+ // `id` is not annotated here — it must be inferred as `string` from `fetch`.
47
+ getKey: (_i, id) => {
48
+ expectTypeOf(id).toEqualTypeOf<string>()
49
+ return id
50
+ },
51
+ fetch: () => (id: string) => of<Widget>({id, label: ''}),
52
+ })
53
+ })
54
+
55
+ test('getState carries TData through the snapshot envelope (not unknown)', () => {
56
+ expectTypeOf(widgets.getState(instance).getCurrent()).toEqualTypeOf<FetcherSnapshot<Widget[]>>()
57
+ })
58
+
59
+ test('the snapshot discriminates data by status', () => {
60
+ const snap = widget.getState(instance, 'w1').getCurrent()
61
+ if (snap.status === 'success') {
62
+ expectTypeOf(snap.data).toEqualTypeOf<Widget>()
63
+ expectTypeOf(snap.dataUpdatedAt).toEqualTypeOf<number>()
64
+ }
65
+ if (snap.status === 'pending') {
66
+ expectTypeOf(snap.data).toEqualTypeOf<undefined>()
67
+ }
68
+ })
69
+
70
+ test('resolveState and refetch resolve to TData', () => {
71
+ expectTypeOf(widgets.resolveState(instance)).resolves.toEqualTypeOf<Widget[]>()
72
+ expectTypeOf(widget.refetch(instance, 'w1')).resolves.toEqualTypeOf<Widget>()
73
+ })
74
+
75
+ test('the params tuple is enforced per fetcher', () => {
76
+ // @ts-expect-error — detail fetcher requires an id
77
+ void widget.getState(instance)
78
+ // @ts-expect-error — id must be a string
79
+ void widget.getState(instance, 123)
80
+ // @ts-expect-error — list fetcher takes no params
81
+ void widgets.getState(instance, 'nope')
82
+ })
83
+
84
+ test('setData updater is typed to TData', () => {
85
+ widget.setData(instance, ['w1'], {id: 'w1', label: 'x'})
86
+ widget.setData(instance, ['w1'], (current) => {
87
+ expectTypeOf(current).toEqualTypeOf<Widget | undefined>()
88
+ return {id: 'w1', label: 'y'}
89
+ })
90
+ // @ts-expect-error — value must be a full Widget
91
+ widget.setData(instance, ['w1'], {id: 'w1'})
92
+ // @ts-expect-error — updater function must return TData
93
+ widget.setData(instance, ['w1'], () => 42)
94
+ })
95
+
96
+ test('defineMutation resolves to a MutationResult of TResult with a typed input', () => {
97
+ const createWidget = defineMutation<{label: string}, Widget>({
98
+ name: 'test-d-create-widget',
99
+ mutationFn: () => (input) => {
100
+ expectTypeOf(input).toEqualTypeOf<{label: string}>()
101
+ return of<Widget>({id: 'x', label: input.label})
102
+ },
103
+ })
104
+ expectTypeOf(createWidget(instance, {label: 'a'})).resolves.toEqualTypeOf<
105
+ MutationResult<Widget>
106
+ >()
107
+ })
108
+
109
+ test('CacheWriter enforces the target fetcher’s TData and TParams', () => {
110
+ defineMutation<void, void>({
111
+ name: 'test-d-write-through',
112
+ mutationFn: () => () => of(undefined),
113
+ onMutate: (write) => {
114
+ write(widget, ['w1'], {id: 'w1', label: 'ok'})
115
+ // @ts-expect-error — value must match the fetcher's TData (Widget)
116
+ write(widget, ['w1'], {nope: true})
117
+ // @ts-expect-error — params must match the fetcher's TParams ([string])
118
+ write(widget, [], {id: 'w1', label: 'ok'})
119
+ },
120
+ })
121
+ })
@@ -0,0 +1,299 @@
1
+ import {NEVER, of, startWith, Subject, throwError} from 'rxjs'
2
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {createSanityInstance, type SanityInstance} from './createSanityInstance'
5
+ import {defineFetcher, defineMutation, type Fetcher} from './fetcherStore'
6
+
7
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
8
+
9
+ describe('fetcherStore', () => {
10
+ let instance: SanityInstance
11
+
12
+ beforeEach(() => {
13
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
14
+ vi.useRealTimers()
15
+ })
16
+
17
+ afterEach(() => {
18
+ instance.dispose()
19
+ })
20
+
21
+ it('fetches on subscribe and exposes a success snapshot', async () => {
22
+ const fetcher = defineFetcher<[number], string>({
23
+ name: 'basic',
24
+ getKey: (_i, id) => `${id}`,
25
+ fetch: () => (id) => of(`data-${id}`),
26
+ })
27
+
28
+ const data = await fetcher.resolveState(instance, 1)
29
+ expect(data).toBe('data-1')
30
+ expect(fetcher.getState(instance, 1).getCurrent()).toMatchObject({
31
+ status: 'success',
32
+ data: 'data-1',
33
+ isFetching: false,
34
+ })
35
+ })
36
+
37
+ it('serves fresh data without refetching, and revalidates once stale', async () => {
38
+ const fetchSpy = vi.fn((id: number) => of(`v-${id}`))
39
+ const fetcher = defineFetcher<[number], string>({
40
+ name: 'swr',
41
+ staleTime: 10_000,
42
+ getKey: (_i, id) => `${id}`,
43
+ fetch: () => fetchSpy,
44
+ })
45
+
46
+ const first = fetcher.getState(instance, 1)
47
+ const unsub1 = first.subscribe()
48
+ await vi.waitFor(() => expect(first.getCurrent().status).toBe('success'))
49
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
50
+ unsub1()
51
+
52
+ // Re-subscribe within staleTime — fresh, so no refetch.
53
+ const second = fetcher.getState(instance, 1)
54
+ const unsub2 = second.subscribe()
55
+ await flush()
56
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
57
+ unsub2()
58
+
59
+ // Invalidation forces staleness — an active subscriber revalidates.
60
+ const third = fetcher.getState(instance, 1)
61
+ const unsub3 = third.subscribe()
62
+ await flush()
63
+ fetcher.invalidate(instance, 1)
64
+ await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2))
65
+ unsub3()
66
+ })
67
+
68
+ it('invalidate refetches an actively-subscribed entry', async () => {
69
+ let n = 0
70
+ const fetcher = defineFetcher<[], number>({
71
+ name: 'invalidate',
72
+ getKey: () => 'k',
73
+ fetch: () => () => of(++n),
74
+ })
75
+
76
+ const source = fetcher.getState(instance)
77
+ const unsub = source.subscribe()
78
+ await vi.waitFor(() => expect(source.getCurrent().data).toBe(1))
79
+
80
+ fetcher.invalidate(instance)
81
+ await vi.waitFor(() => expect(source.getCurrent().data).toBe(2))
82
+ unsub()
83
+ })
84
+
85
+ it('refetch bypasses staleness and resolves with fresh data', async () => {
86
+ let n = 0
87
+ const fetcher = defineFetcher<[], number>({
88
+ name: 'refetch',
89
+ staleTime: 60_000,
90
+ getKey: () => 'k',
91
+ fetch: () => () => of(++n),
92
+ })
93
+
94
+ expect(await fetcher.resolveState(instance)).toBe(1)
95
+ expect(await fetcher.refetch(instance)).toBe(2)
96
+ })
97
+
98
+ it('surfaces an initial failure as an error snapshot, background failure on the success arm', async () => {
99
+ let attempt = 0
100
+ const fetcher = defineFetcher<[], string>({
101
+ name: 'errors',
102
+ getKey: () => 'k',
103
+ fetch: () => () => (attempt++ === 0 ? throwError(() => new Error('boom')) : of('recovered')),
104
+ })
105
+
106
+ await expect(fetcher.resolveState(instance)).rejects.toThrow('boom')
107
+ expect(fetcher.getState(instance).getCurrent().status).toBe('error')
108
+
109
+ // Now succeed, then fail a background revalidation: data stays, error attaches.
110
+ await fetcher.refetch(instance)
111
+ expect(fetcher.getState(instance).getCurrent()).toMatchObject({
112
+ status: 'success',
113
+ data: 'recovered',
114
+ })
115
+ })
116
+
117
+ it('pre-seeds an entry via initialData without fetching when the seed is fresh', async () => {
118
+ const fetchSpy = vi.fn(() => of('fetched'))
119
+ const fetcher = defineFetcher<[], string>({
120
+ name: 'seeded',
121
+ staleTime: 10_000,
122
+ getKey: () => 'k',
123
+ fetch: () => fetchSpy,
124
+ initialData: () => ({data: 'seeded', dataUpdatedAt: Date.now()}),
125
+ })
126
+
127
+ const source = fetcher.getState(instance)
128
+ const unsub = source.subscribe()
129
+ await flush()
130
+
131
+ expect(source.getCurrent()).toMatchObject({status: 'success', data: 'seeded'})
132
+ expect(fetchSpy).not.toHaveBeenCalled()
133
+ unsub()
134
+ })
135
+
136
+ it('setData writes optimistically and can be undone', () => {
137
+ const fetcher = defineFetcher<[], string>({
138
+ name: 'setdata',
139
+ getKey: () => 'k',
140
+ fetch: () => () => NEVER,
141
+ })
142
+
143
+ const {undo} = fetcher.setData(instance, [], 'written')
144
+ expect(fetcher.getState(instance).getCurrent().data).toBe('written')
145
+
146
+ undo()
147
+ expect(fetcher.getState(instance).getCurrent().status).toBe('pending')
148
+ })
149
+
150
+ it('garbage-collects an entry written without a subscriber after gcTime', async () => {
151
+ const fetcher = defineFetcher<[], string>({
152
+ name: 'gc-orphan',
153
+ gcTime: 5,
154
+ getKey: () => 'k',
155
+ fetch: () => () => NEVER,
156
+ })
157
+
158
+ // No subscriber ever holds this entry (as an optimistic mutation write would),
159
+ // so only setData's own GC scheduling can clean it up.
160
+ fetcher.setData(instance, [], 'written')
161
+ expect(fetcher.getState(instance).getCurrent().data).toBe('written')
162
+
163
+ await vi.waitFor(() => expect(fetcher.getState(instance).getCurrent().status).toBe('pending'))
164
+ })
165
+
166
+ it('rolls back optimistic writes when a mutation fails', async () => {
167
+ const fetcher = defineFetcher<[], string>({
168
+ name: 'mutate-rollback',
169
+ getKey: () => 'k',
170
+ fetch: () => () => NEVER,
171
+ })
172
+ fetcher.setData(instance, [], 'server')
173
+
174
+ const failing = defineMutation<string, never>({
175
+ name: 'rename-fail',
176
+ mutationFn: () => () => throwError(() => new Error('rejected')),
177
+ onMutate: (write, value) => write(fetcher, [], value),
178
+ })
179
+
180
+ await expect(failing(instance, 'optimistic')).rejects.toThrow('rejected')
181
+ expect(fetcher.getState(instance).getCurrent().data).toBe('server')
182
+ })
183
+
184
+ it('reconciles tagged entries after a successful mutation', async () => {
185
+ let n = 0
186
+ const list = defineFetcher<[], number>({
187
+ name: 'list',
188
+ getKey: () => 'all',
189
+ tags: [{type: 'thing', id: 'LIST'}],
190
+ fetch: () => () => of(++n),
191
+ })
192
+
193
+ const source = list.getState(instance)
194
+ const unsub = source.subscribe()
195
+ await vi.waitFor(() => expect(source.getCurrent().data).toBe(1))
196
+
197
+ const create = defineMutation<void, {ok: true}>({
198
+ name: 'create-thing',
199
+ mutationFn: () => () => of({ok: true}),
200
+ invalidates: [{type: 'thing', id: 'LIST'}],
201
+ })
202
+
203
+ const {invalidated} = await create(instance, undefined)
204
+ await invalidated
205
+ expect(source.getCurrent().data).toBe(2)
206
+ unsub()
207
+ })
208
+
209
+ it('discards a revalidation result that lands after an optimistic write', async () => {
210
+ const server = new Subject<string>()
211
+ const fetcher: Fetcher<[], string> = defineFetcher<[], string>({
212
+ name: 'version-guard',
213
+ staleTime: 0,
214
+ getKey: () => 'k',
215
+ fetch: () => () => server.pipe(startWith('initial')),
216
+ })
217
+
218
+ const source = fetcher.getState(instance)
219
+ const unsub = source.subscribe()
220
+ await vi.waitFor(() => expect(source.getCurrent().data).toBe('initial'))
221
+
222
+ // Force a refetch (in flight, no value yet), then write optimistically.
223
+ const refetching = fetcher.refetch(instance)
224
+ fetcher.setData(instance, [], 'optimistic')
225
+ expect(source.getCurrent().data).toBe('optimistic')
226
+
227
+ // Late server value must NOT clobber the optimistic write.
228
+ server.next('late-server')
229
+ await refetching
230
+ expect(source.getCurrent().data).toBe('optimistic')
231
+ unsub()
232
+ })
233
+
234
+ it('settles a pre-empted in-flight fetch when a forced refetch replaces it', async () => {
235
+ const server = new Subject<string>()
236
+ const fetcher = defineFetcher<[], string>({
237
+ name: 'preempt',
238
+ getKey: () => 'k',
239
+ fetch: () => () => server,
240
+ })
241
+
242
+ // First refetch is in flight with no value yet; a second forced refetch
243
+ // pre-empts it. Both promises must settle rather than hang.
244
+ const first = fetcher.refetch(instance)
245
+ const second = fetcher.refetch(instance)
246
+
247
+ server.next('data')
248
+ server.complete()
249
+
250
+ await expect(first).resolves.toBe('data')
251
+ await expect(second).resolves.toBe('data')
252
+ })
253
+
254
+ it('clears isFetching when a discarded revalidation runs on a non-completing source', async () => {
255
+ const server = new Subject<string>()
256
+ const fetcher = defineFetcher<[], string>({
257
+ name: 'version-guard-fetching',
258
+ staleTime: 0,
259
+ getKey: () => 'k',
260
+ fetch: () => () => server,
261
+ })
262
+
263
+ const source = fetcher.getState(instance)
264
+ const unsub = source.subscribe()
265
+ await vi.waitFor(() => expect(source.getCurrent().isFetching).toBe(true))
266
+
267
+ // Optimistic write bumps the version while the fetch is still in flight.
268
+ fetcher.setData(instance, [], 'optimistic')
269
+
270
+ // The late server value is discarded (version guard) — and because the source
271
+ // never completes, isFetching must be cleared here, not left stuck true.
272
+ server.next('late')
273
+ await flush()
274
+ expect(source.getCurrent()).toMatchObject({data: 'optimistic', isFetching: false})
275
+ unsub()
276
+ })
277
+
278
+ it('polls on refetchInterval while an entry has subscribers', async () => {
279
+ let n = 0
280
+ const fetcher = defineFetcher<[], number>({
281
+ name: 'polling',
282
+ staleTime: 60_000,
283
+ refetchInterval: 10,
284
+ getKey: () => 'k',
285
+ fetch: () => () => of(++n),
286
+ })
287
+
288
+ const source = fetcher.getState(instance)
289
+ const unsub = source.subscribe()
290
+ await vi.waitFor(() => expect((source.getCurrent().data ?? 0) >= 1).toBe(true))
291
+
292
+ // Fresh per staleTime, so any further increase can only come from the interval.
293
+ const before = source.getCurrent().data ?? 0
294
+ await vi.waitFor(() => expect((source.getCurrent().data ?? 0) > before).toBe(true), {
295
+ timeout: 500,
296
+ })
297
+ unsub()
298
+ })
299
+ })