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

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 (197) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/_internal.js +134 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  7. package/dist/_chunks-es/telemetryManager.js +93 -0
  8. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  9. package/dist/_exports/_internal.d.ts +95 -0
  10. package/dist/_exports/_internal.d.ts.map +1 -0
  11. package/dist/_exports/_internal.js +24 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +1079 -1076
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1967 -2302
  16. package/dist/index.js.map +1 -1
  17. package/package.json +48 -47
  18. package/src/_exports/_internal.ts +26 -0
  19. package/src/_exports/index.ts +120 -11
  20. package/src/access/checkPermissions.test.ts +55 -0
  21. package/src/access/checkPermissions.ts +54 -0
  22. package/src/agent/agentActions.test.ts +16 -60
  23. package/src/agent/agentActions.ts +25 -38
  24. package/src/applications/applications.test-d.ts +83 -0
  25. package/src/applications/applications.test.ts +130 -0
  26. package/src/applications/applications.ts +364 -0
  27. package/src/applications/userApplications.test.ts +126 -0
  28. package/src/applications/userApplications.ts +261 -0
  29. package/src/auth/authLogger.ts +30 -0
  30. package/src/auth/authMode.test.ts +25 -0
  31. package/src/auth/authMode.ts +6 -3
  32. package/src/auth/authStore.test.ts +194 -13
  33. package/src/auth/authStore.ts +59 -26
  34. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  35. package/src/auth/getOrganizationVerificationState.ts +4 -4
  36. package/src/auth/handleAuthCallback.test.ts +35 -1
  37. package/src/auth/handleAuthCallback.ts +25 -6
  38. package/src/auth/logout.test.ts +75 -2
  39. package/src/auth/logout.ts +23 -4
  40. package/src/auth/refreshStampedToken.test.ts +219 -469
  41. package/src/auth/refreshStampedToken.ts +79 -195
  42. package/src/auth/studioAuth.ts +1 -1
  43. package/src/auth/studioModeAuth.ts +2 -1
  44. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  45. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  46. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  47. package/src/client/clientStore.test.ts +62 -37
  48. package/src/client/clientStore.ts +45 -75
  49. package/src/client/liveEvents.test.ts +151 -0
  50. package/src/client/liveEvents.ts +107 -0
  51. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  52. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  53. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  54. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  55. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  56. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  57. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  58. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  59. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  60. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  61. package/src/comlink/node/getNodeState.test.ts +1 -1
  62. package/src/comlink/node/getNodeState.ts +2 -1
  63. package/src/config/__tests__/handles.test.ts +18 -12
  64. package/src/config/handles.ts +25 -7
  65. package/src/config/sanityConfig.ts +109 -79
  66. package/src/datasets/datasets.test.ts +2 -2
  67. package/src/datasets/datasets.ts +10 -4
  68. package/src/document/actions.test.ts +115 -33
  69. package/src/document/actions.ts +212 -15
  70. package/src/document/applyDocumentActions.test.ts +38 -12
  71. package/src/document/applyDocumentActions.ts +30 -9
  72. package/src/document/documentConstants.ts +7 -0
  73. package/src/document/documentStore.concurrency.test.ts +1306 -0
  74. package/src/document/documentStore.test.ts +673 -278
  75. package/src/document/documentStore.ts +162 -71
  76. package/src/document/events.test.ts +57 -2
  77. package/src/document/events.ts +82 -26
  78. package/src/document/listen.test.ts +56 -0
  79. package/src/document/listen.ts +74 -21
  80. package/src/document/listenerEventOperators.test.ts +311 -0
  81. package/src/document/listenerEventOperators.ts +217 -0
  82. package/src/document/patchOperations.test.ts +49 -2
  83. package/src/document/patchOperations.ts +50 -0
  84. package/src/document/permissions.test.ts +105 -60
  85. package/src/document/permissions.ts +44 -19
  86. package/src/document/processActions/create.ts +138 -0
  87. package/src/document/processActions/delete.ts +100 -0
  88. package/src/document/processActions/discard.ts +63 -0
  89. package/src/document/processActions/edit.ts +154 -0
  90. package/src/document/processActions/processActions.ts +218 -0
  91. package/src/document/processActions/publish.ts +120 -0
  92. package/src/document/processActions/releaseArchive.ts +77 -0
  93. package/src/document/processActions/releaseCreate.ts +59 -0
  94. package/src/document/processActions/releaseDelete.ts +65 -0
  95. package/src/document/processActions/releaseEdit.ts +37 -0
  96. package/src/document/processActions/releasePublish.ts +45 -0
  97. package/src/document/processActions/releaseSchedule.ts +87 -0
  98. package/src/document/processActions/releaseUtil.ts +31 -0
  99. package/src/document/processActions/shared.ts +308 -0
  100. package/src/document/processActions/unpublish.ts +85 -0
  101. package/src/document/processActions.test.ts +1129 -121
  102. package/src/document/reducers.test.ts +639 -64
  103. package/src/document/reducers.ts +187 -48
  104. package/src/document/resourceRules.test.ts +178 -0
  105. package/src/document/resourceRules.ts +117 -0
  106. package/src/document/sharedListener.test.ts +16 -14
  107. package/src/document/sharedListener.ts +24 -8
  108. package/src/document/util.ts +14 -0
  109. package/src/favorites/favorites.test.ts +5 -6
  110. package/src/installations/installations.test-d.ts +68 -0
  111. package/src/installations/installations.test.ts +62 -0
  112. package/src/installations/installations.ts +202 -0
  113. package/src/organization/organization.test-d.ts +36 -0
  114. package/src/organization/organization.test.ts +138 -0
  115. package/src/organization/organization.ts +138 -0
  116. package/src/organizations/organizations.test-d.ts +56 -0
  117. package/src/organizations/organizations.test.ts +150 -0
  118. package/src/organizations/organizations.ts +107 -0
  119. package/src/presence/bifurTransport.test.ts +46 -6
  120. package/src/presence/bifurTransport.ts +19 -2
  121. package/src/presence/presenceStore.test.ts +94 -23
  122. package/src/presence/presenceStore.ts +85 -29
  123. package/src/preview/getPreviewState.test.ts +137 -0
  124. package/src/preview/getPreviewState.ts +69 -0
  125. package/src/preview/previewProjectionUtils.test.ts +9 -22
  126. package/src/preview/previewProjectionUtils.ts +7 -3
  127. package/src/preview/resolvePreview.test.ts +64 -0
  128. package/src/preview/resolvePreview.ts +43 -0
  129. package/src/preview/types.ts +10 -0
  130. package/src/project/project.test-d.ts +37 -0
  131. package/src/project/project.test.ts +157 -12
  132. package/src/project/project.ts +138 -25
  133. package/src/projection/getProjectionState.test.ts +18 -20
  134. package/src/projection/getProjectionState.ts +23 -3
  135. package/src/projection/projectionQuery.ts +5 -5
  136. package/src/projection/projectionStore.test.ts +1 -1
  137. package/src/projection/resolveProjection.test.ts +1 -2
  138. package/src/projection/resolveProjection.ts +16 -0
  139. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  140. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  141. package/src/projection/types.ts +8 -0
  142. package/src/projects/projects.test-d.ts +32 -0
  143. package/src/projects/projects.test.ts +108 -42
  144. package/src/projects/projects.ts +63 -27
  145. package/src/query/queryStore.test.ts +259 -123
  146. package/src/query/queryStore.ts +78 -63
  147. package/src/query/reducers.test.ts +0 -42
  148. package/src/query/reducers.ts +2 -18
  149. package/src/releases/getPerspectiveState.test.ts +17 -23
  150. package/src/releases/getPerspectiveState.ts +8 -20
  151. package/src/releases/observeReleases.test.ts +127 -0
  152. package/src/releases/observeReleases.ts +102 -0
  153. package/src/releases/releasesStore.test.ts +100 -53
  154. package/src/releases/releasesStore.ts +69 -44
  155. package/src/releases/utils/sortReleases.test.ts +38 -2
  156. package/src/releases/utils/sortReleases.ts +15 -10
  157. package/src/store/createActionBinder.test.ts +87 -90
  158. package/src/store/createActionBinder.ts +79 -25
  159. package/src/store/createSanityInstance.test.ts +83 -13
  160. package/src/store/createSanityInstance.ts +77 -2
  161. package/src/store/createStateSourceAction.test.ts +64 -2
  162. package/src/store/createStateSourceAction.ts +39 -44
  163. package/src/store/createStoreInstance.test.ts +4 -2
  164. package/src/store/fetcherStore.test-d.ts +121 -0
  165. package/src/store/fetcherStore.test.ts +299 -0
  166. package/src/store/fetcherStore.ts +801 -0
  167. package/src/telemetry/environment.test.ts +119 -0
  168. package/src/telemetry/environment.ts +92 -0
  169. package/src/telemetry/events.ts +42 -0
  170. package/src/telemetry/initTelemetry.test.ts +432 -0
  171. package/src/telemetry/initTelemetry.ts +218 -0
  172. package/src/telemetry/telemetryManager.test.ts +327 -0
  173. package/src/telemetry/telemetryManager.ts +199 -0
  174. package/src/users/reducers.test.ts +6 -1
  175. package/src/users/reducers.ts +5 -6
  176. package/src/users/types.ts +4 -4
  177. package/src/users/usersStore.test.ts +16 -12
  178. package/src/users/usersStore.ts +5 -1
  179. package/src/utils/buildQuery.test.ts +14 -0
  180. package/src/utils/buildQuery.ts +17 -0
  181. package/src/utils/createFetcherStore.test.ts +7 -5
  182. package/src/utils/createFetcherStore.ts +8 -5
  183. package/src/utils/getStagingApiHost.test.ts +21 -0
  184. package/src/utils/getStagingApiHost.ts +14 -0
  185. package/src/utils/ids.test.ts +1 -29
  186. package/src/utils/ids.ts +0 -10
  187. package/src/utils/includeShape.ts +36 -0
  188. package/src/utils/isImportError.test.ts +72 -0
  189. package/src/utils/isImportError.ts +34 -0
  190. package/src/utils/logger.test.ts +12 -0
  191. package/src/utils/logger.ts +8 -3
  192. package/src/utils/object.test.ts +95 -0
  193. package/src/utils/object.ts +142 -0
  194. package/src/utils/setCleanupTimeout.ts +37 -0
  195. package/src/document/processActions.ts +0 -707
  196. package/src/utils/defineIntent.test.ts +0 -477
  197. package/src/utils/defineIntent.ts +0 -244
@@ -1,9 +1,10 @@
1
1
  import {type SanityConfig} from '../config/sanityConfig'
2
2
  import {insecureRandomId} from '../utils/ids'
3
3
  import {createLogger, type InstanceContext} from '../utils/logger'
4
+ import {pickProperties} from '../utils/object'
4
5
 
5
6
  /**
6
- * Represents a Sanity.io resource instance with its own configuration and lifecycle.
7
+ * Represents a Sanity.io resource instance with its own configuration and lifecycle
7
8
  *
8
9
  * @public
9
10
  */
@@ -14,7 +15,9 @@ export interface SanityInstance {
14
15
  */
15
16
  readonly instanceId: string
16
17
 
17
- /** Resolved configuration for this instance */
18
+ /**
19
+ * Resolved configuration for this instance
20
+ */
18
21
  readonly config: SanityConfig
19
22
 
20
23
  /**
@@ -35,12 +38,36 @@ export interface SanityInstance {
35
38
  * @returns Function to unsubscribe the callback
36
39
  */
37
40
  onDispose(cb: () => void): () => void
41
+
42
+ /**
43
+ * Gets the parent instance in the hierarchy
44
+ * @returns Parent instance or undefined if this is the root
45
+ * @deprecated The parent/child instance hierarchy is deprecated. Use a single SanityInstance instead.
46
+ */
47
+ getParent(): SanityInstance | undefined
48
+
49
+ /**
50
+ * Creates a child instance with merged configuration
51
+ * @param config - Configuration to merge with parent values
52
+ * @deprecated The parent/child instance hierarchy is deprecated. Use a single SanityInstance instead.
53
+ */
54
+ createChild(config: SanityConfig): SanityInstance
55
+
56
+ /**
57
+ * Traverses the instance hierarchy to find the first instance whose configuration
58
+ * matches the given target config using a shallow comparison.
59
+ * @param targetConfig - A partial configuration object containing key-value pairs to match.
60
+ * @returns The first matching instance or undefined if no match is found.
61
+ * @deprecated The parent/child instance hierarchy is deprecated. Use a single SanityInstance instead.
62
+ */
63
+ match(targetConfig: Partial<SanityConfig>): SanityInstance | undefined
38
64
  }
39
65
 
40
66
  /**
41
67
  * Creates a new Sanity resource instance
42
68
  * @param config - Configuration for the instance (optional)
43
69
  * @returns A configured SanityInstance
70
+ * @remarks When creating child instances, configurations are merged with parent values
44
71
  *
45
72
  * @public
46
73
  */
@@ -49,21 +76,32 @@ export function createSanityInstance(config: SanityConfig = {}): SanityInstance
49
76
  const disposeListeners = new Map<string, () => void>()
50
77
  const disposed = {current: false}
51
78
 
79
+ // Create instance context for logging
52
80
  const instanceContext: InstanceContext = {
53
81
  instanceId,
82
+ projectId: config.projectId,
83
+ dataset: config.dataset,
54
84
  }
55
85
 
86
+ // Create logger with instance context
56
87
  const logger = createLogger('sdk', {instanceContext})
57
88
 
89
+ // Log instance creation
58
90
  logger.info('Sanity instance created', {
91
+ hasProjectId: !!config.projectId,
92
+ hasDataset: !!config.dataset,
59
93
  hasAuth: !!config.auth,
60
94
  hasPerspective: !!config.perspective,
61
95
  })
62
96
 
97
+ // Log configuration details at debug level
63
98
  logger.debug('Instance configuration', {
99
+ projectId: config.projectId,
100
+ dataset: config.dataset,
64
101
  perspective: config.perspective,
65
102
  hasStudioConfig: !!config.studio,
66
103
  hasStudioTokenSource: !!config.studio?.auth?.token,
104
+ legacyStudioMode: config.studioMode?.enabled,
67
105
  hasAuthProviders: !!config.auth?.providers,
68
106
  hasAuthToken: !!config.auth?.token,
69
107
  })
@@ -93,6 +131,43 @@ export function createSanityInstance(config: SanityConfig = {}): SanityInstance
93
131
  disposeListeners.delete(listenerId)
94
132
  }
95
133
  },
134
+ getParent: () => undefined,
135
+ createChild: (next) => {
136
+ logger.debug('Creating child instance', {
137
+ parentInstanceId: instanceId.slice(0, 8),
138
+ overridingProjectId: !!next.projectId,
139
+ overridingDataset: !!next.dataset,
140
+ overridingAuth: !!next.auth,
141
+ })
142
+ const child = Object.assign(
143
+ createSanityInstance({
144
+ ...config,
145
+ ...next,
146
+ ...(config.auth === next.auth
147
+ ? config.auth
148
+ : config.auth && next.auth && {auth: {...config.auth, ...next.auth}}),
149
+ }),
150
+ {getParent: () => instance},
151
+ )
152
+ logger.trace('Child instance created', {
153
+ internal: true,
154
+ childInstanceId: child.instanceId.slice(0, 8),
155
+ })
156
+ return child
157
+ },
158
+ match: (targetConfig) => {
159
+ if (
160
+ Object.entries(pickProperties(targetConfig, ['auth', 'projectId', 'dataset'])).every(
161
+ ([key, value]) => config[key as keyof SanityConfig] === value,
162
+ )
163
+ ) {
164
+ return instance
165
+ }
166
+
167
+ const parent = instance.getParent()
168
+ if (parent) return parent.match(targetConfig)
169
+ return undefined
170
+ },
96
171
  }
97
172
 
98
173
  return instance
@@ -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
  * )
@@ -155,13 +155,13 @@ interface StateSourceOptions<TState, TParams extends unknown[], TReturn, TKey> {
155
155
  * // ...
156
156
  * })
157
157
  *
158
- * const getDocument = bindActionByResource(
158
+ * const getDocument = bindActionByDataset(
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
+ })