@sanity/sdk 0.0.0-alpha.21 → 0.0.0-alpha.23

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 (127) hide show
  1. package/dist/index.d.ts +428 -325
  2. package/dist/index.js +1618 -1553
  3. package/dist/index.js.map +1 -1
  4. package/package.json +6 -7
  5. package/src/_exports/index.ts +31 -30
  6. package/src/auth/authStore.test.ts +149 -104
  7. package/src/auth/authStore.ts +51 -100
  8. package/src/auth/handleAuthCallback.test.ts +67 -34
  9. package/src/auth/handleAuthCallback.ts +8 -7
  10. package/src/auth/logout.test.ts +61 -29
  11. package/src/auth/logout.ts +26 -28
  12. package/src/auth/refreshStampedToken.test.ts +9 -9
  13. package/src/auth/refreshStampedToken.ts +62 -56
  14. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +5 -5
  15. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +45 -47
  16. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +4 -5
  17. package/src/auth/subscribeToStorageEventsAndSetToken.ts +22 -24
  18. package/src/client/clientStore.test.ts +131 -67
  19. package/src/client/clientStore.ts +117 -116
  20. package/src/comlink/controller/actions/destroyController.test.ts +38 -13
  21. package/src/comlink/controller/actions/destroyController.ts +11 -15
  22. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +56 -27
  23. package/src/comlink/controller/actions/getOrCreateChannel.ts +37 -35
  24. package/src/comlink/controller/actions/getOrCreateController.test.ts +27 -16
  25. package/src/comlink/controller/actions/getOrCreateController.ts +23 -22
  26. package/src/comlink/controller/actions/releaseChannel.test.ts +37 -13
  27. package/src/comlink/controller/actions/releaseChannel.ts +22 -21
  28. package/src/comlink/controller/comlinkControllerStore.test.ts +65 -36
  29. package/src/comlink/controller/comlinkControllerStore.ts +44 -5
  30. package/src/comlink/node/actions/getOrCreateNode.test.ts +31 -15
  31. package/src/comlink/node/actions/getOrCreateNode.ts +30 -29
  32. package/src/comlink/node/actions/releaseNode.test.ts +75 -55
  33. package/src/comlink/node/actions/releaseNode.ts +19 -21
  34. package/src/comlink/node/comlinkNodeStore.test.ts +6 -11
  35. package/src/comlink/node/comlinkNodeStore.ts +22 -5
  36. package/src/config/authConfig.ts +79 -0
  37. package/src/config/sanityConfig.ts +48 -0
  38. package/src/datasets/datasets.test.ts +2 -2
  39. package/src/datasets/datasets.ts +18 -5
  40. package/src/document/actions.test.ts +22 -10
  41. package/src/document/actions.ts +44 -56
  42. package/src/document/applyDocumentActions.test.ts +96 -36
  43. package/src/document/applyDocumentActions.ts +140 -99
  44. package/src/document/documentStore.test.ts +103 -155
  45. package/src/document/documentStore.ts +247 -237
  46. package/src/document/listen.ts +56 -55
  47. package/src/document/patchOperations.ts +0 -43
  48. package/src/document/permissions.test.ts +25 -12
  49. package/src/document/permissions.ts +11 -4
  50. package/src/document/processActions.test.ts +41 -8
  51. package/src/document/reducers.test.ts +87 -16
  52. package/src/document/reducers.ts +2 -2
  53. package/src/document/sharedListener.test.ts +34 -16
  54. package/src/document/sharedListener.ts +33 -11
  55. package/src/preview/getPreviewState.test.ts +40 -39
  56. package/src/preview/getPreviewState.ts +68 -56
  57. package/src/preview/previewConstants.ts +43 -0
  58. package/src/preview/previewQuery.test.ts +1 -1
  59. package/src/preview/previewQuery.ts +4 -5
  60. package/src/preview/previewStore.test.ts +13 -58
  61. package/src/preview/previewStore.ts +7 -21
  62. package/src/preview/resolvePreview.test.ts +33 -104
  63. package/src/preview/resolvePreview.ts +11 -21
  64. package/src/preview/subscribeToStateAndFetchBatches.test.ts +96 -97
  65. package/src/preview/subscribeToStateAndFetchBatches.ts +85 -81
  66. package/src/preview/util.ts +1 -0
  67. package/src/project/project.test.ts +3 -3
  68. package/src/project/project.ts +28 -5
  69. package/src/projection/getProjectionState.test.ts +69 -49
  70. package/src/projection/getProjectionState.ts +42 -50
  71. package/src/projection/projectionQuery.ts +1 -1
  72. package/src/projection/projectionStore.test.ts +13 -51
  73. package/src/projection/projectionStore.ts +6 -18
  74. package/src/projection/resolveProjection.test.ts +32 -127
  75. package/src/projection/resolveProjection.ts +15 -28
  76. package/src/projection/subscribeToStateAndFetchBatches.test.ts +105 -90
  77. package/src/projection/subscribeToStateAndFetchBatches.ts +94 -81
  78. package/src/projection/util.ts +2 -0
  79. package/src/projects/projects.test.ts +13 -4
  80. package/src/projects/projects.ts +6 -1
  81. package/src/query/queryStore.test.ts +10 -47
  82. package/src/query/queryStore.ts +151 -133
  83. package/src/query/queryStoreConstants.ts +2 -0
  84. package/src/store/createActionBinder.test.ts +153 -0
  85. package/src/store/createActionBinder.ts +176 -0
  86. package/src/store/createSanityInstance.test.ts +84 -0
  87. package/src/store/createSanityInstance.ts +124 -0
  88. package/src/store/createStateSourceAction.test.ts +196 -0
  89. package/src/store/createStateSourceAction.ts +260 -0
  90. package/src/store/createStoreInstance.test.ts +81 -0
  91. package/src/store/createStoreInstance.ts +80 -0
  92. package/src/store/createStoreState.test.ts +85 -0
  93. package/src/store/createStoreState.ts +92 -0
  94. package/src/store/defineStore.test.ts +18 -0
  95. package/src/store/defineStore.ts +81 -0
  96. package/src/users/reducers.test.ts +318 -0
  97. package/src/users/reducers.ts +88 -0
  98. package/src/users/types.ts +46 -4
  99. package/src/users/usersConstants.ts +4 -0
  100. package/src/users/usersStore.test.ts +350 -223
  101. package/src/users/usersStore.ts +285 -149
  102. package/src/utils/createFetcherStore.test.ts +6 -7
  103. package/src/utils/createFetcherStore.ts +150 -153
  104. package/src/{common/util.test.ts → utils/hashString.test.ts} +1 -1
  105. package/src/auth/fetchLoginUrls.test.ts +0 -163
  106. package/src/auth/fetchLoginUrls.ts +0 -74
  107. package/src/common/createLiveEventSubscriber.test.ts +0 -121
  108. package/src/common/createLiveEventSubscriber.ts +0 -55
  109. package/src/common/types.ts +0 -4
  110. package/src/instance/identity.test.ts +0 -46
  111. package/src/instance/identity.ts +0 -29
  112. package/src/instance/sanityInstance.test.ts +0 -77
  113. package/src/instance/sanityInstance.ts +0 -57
  114. package/src/instance/types.ts +0 -37
  115. package/src/preview/getPreviewProjection.ts +0 -45
  116. package/src/resources/README.md +0 -370
  117. package/src/resources/createAction.test.ts +0 -101
  118. package/src/resources/createAction.ts +0 -44
  119. package/src/resources/createResource.test.ts +0 -112
  120. package/src/resources/createResource.ts +0 -102
  121. package/src/resources/createStateSourceAction.test.ts +0 -114
  122. package/src/resources/createStateSourceAction.ts +0 -83
  123. package/src/resources/createStore.test.ts +0 -67
  124. package/src/resources/createStore.ts +0 -46
  125. package/src/store/createStore.test.ts +0 -108
  126. package/src/store/createStore.ts +0 -106
  127. /package/src/{common/util.ts → utils/hashString.ts} +0 -0
@@ -0,0 +1,196 @@
1
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {createSanityInstance, type SanityInstance} from './createSanityInstance'
4
+ import {createStateSourceAction, type SelectorContext} from './createStateSourceAction'
5
+ import {createStoreState, type StoreState} from './createStoreState'
6
+
7
+ interface CountStoreState {
8
+ count: number
9
+ items: string[]
10
+ }
11
+
12
+ describe('createStateSourceAction', () => {
13
+ let state: StoreState<CountStoreState>
14
+ let instance: SanityInstance
15
+
16
+ beforeEach(() => {
17
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
18
+ state = createStoreState({count: 0, items: [] as string[]}, {name: 'test-store'})
19
+ })
20
+
21
+ it('should create a source that provides current state through getCurrent', () => {
22
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
23
+ const action = createStateSourceAction(selector)
24
+ const source = action({state, instance})
25
+
26
+ expect(source.getCurrent()).toBe(0)
27
+ state.set('test', {count: 5})
28
+ expect(source.getCurrent()).toBe(5)
29
+ })
30
+
31
+ it('should call onStoreChanged when state changes', () => {
32
+ const onStoreChanged = vi.fn()
33
+ const source = createStateSourceAction({
34
+ selector: ({state: s}: SelectorContext<CountStoreState>) => s.count,
35
+ isEqual: (a, b) => a === b,
36
+ })({state, instance})
37
+
38
+ const unsubscribe = source.subscribe(onStoreChanged)
39
+
40
+ state.set('inc', (s) => ({count: s.count + 1}))
41
+ expect(onStoreChanged).toHaveBeenCalledTimes(1)
42
+
43
+ state.set('noop', (s) => s)
44
+ expect(onStoreChanged).toHaveBeenCalledTimes(1) // No change
45
+
46
+ unsubscribe()
47
+ state.set('inc2', (s) => ({count: s.count + 1}))
48
+ expect(onStoreChanged).toHaveBeenCalledTimes(1)
49
+ })
50
+
51
+ it('should call onSubscribe handler when subscription starts', () => {
52
+ const onSubscribe = vi.fn(() => () => {})
53
+ const source = createStateSourceAction({
54
+ selector: ({state: s}: SelectorContext<CountStoreState>) => s.items,
55
+ onSubscribe,
56
+ })({state, instance})
57
+
58
+ const unsubscribe = source.subscribe()
59
+ expect(onSubscribe).toHaveBeenCalledWith(
60
+ expect.objectContaining({state, instance}),
61
+ // No params in this case
62
+ )
63
+
64
+ unsubscribe()
65
+ })
66
+
67
+ it('should support parameterized selectors', () => {
68
+ const action = createStateSourceAction({
69
+ selector: ({state: s}: SelectorContext<CountStoreState>, index: number) => s.items[index],
70
+ })
71
+ const source = action({state, instance}, 0)
72
+
73
+ state.set('add', {items: ['first']})
74
+ expect(source.getCurrent()).toBe('first')
75
+ })
76
+
77
+ it('should handle selector errors in observable', () => {
78
+ const error = new Error('Selector failed')
79
+ const source = createStateSourceAction({
80
+ selector: () => {
81
+ throw error
82
+ },
83
+ })({state, instance})
84
+
85
+ const errorHandler = vi.fn()
86
+ source.observable.subscribe({error: errorHandler})
87
+
88
+ state.set('trigger', {count: 1})
89
+ expect(errorHandler).toHaveBeenCalledWith(error)
90
+ })
91
+
92
+ it('should use custom equality check', () => {
93
+ const isEqual = vi.fn((a: number[], b: number[]) => a.length === b.length)
94
+ const source = createStateSourceAction({
95
+ selector: ({state: s}: SelectorContext<CountStoreState>) => s.items.map((i) => i.length),
96
+ isEqual,
97
+ })({state, instance})
98
+
99
+ const onChange = vi.fn()
100
+ source.subscribe(onChange)
101
+
102
+ // Same length, different contents
103
+ state.set('add1', {items: ['a']})
104
+ state.set('add2', {items: ['b']})
105
+
106
+ expect(isEqual).toHaveBeenCalledTimes(2)
107
+ expect(onChange).toHaveBeenCalledTimes(1) // Only first change
108
+ })
109
+
110
+ it('should cleanup onSubscribe when unsubscribed', () => {
111
+ const cleanup = vi.fn()
112
+ const source = createStateSourceAction({
113
+ selector: ({state: s}: SelectorContext<CountStoreState>) => s.count,
114
+ onSubscribe: () => cleanup,
115
+ })({state, instance})
116
+
117
+ const unsubscribe = source.subscribe()
118
+ unsubscribe()
119
+ expect(cleanup).toHaveBeenCalledTimes(1)
120
+ })
121
+
122
+ it('should share observable between subscribers', () => {
123
+ const source = createStateSourceAction(
124
+ ({state: s}: SelectorContext<CountStoreState>) => s.count,
125
+ )({
126
+ state,
127
+ instance,
128
+ })
129
+
130
+ const subscriber1 = vi.fn()
131
+ const subscriber2 = vi.fn()
132
+
133
+ const subscription1 = source.observable.subscribe(subscriber1)
134
+ const subscription2 = source.observable.subscribe(subscriber2)
135
+
136
+ state.set('inc', {count: 1})
137
+
138
+ expect(subscriber1).toHaveBeenCalledWith(1)
139
+ expect(subscriber2).toHaveBeenCalledWith(1)
140
+
141
+ subscription1.unsubscribe()
142
+ subscription2.unsubscribe()
143
+ })
144
+
145
+ it('should cache selector context per state object', () => {
146
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
147
+ const source = createStateSourceAction(selector)({state, instance})
148
+
149
+ // Initial call creates context
150
+ expect(source.getCurrent()).toBe(0)
151
+ expect(selector).toHaveBeenCalledTimes(1)
152
+ const firstContext = selector.mock.calls[0][0]
153
+
154
+ // Subsequent call with same state reuses context
155
+ expect(source.getCurrent()).toBe(0)
156
+ expect(selector).toHaveBeenCalledTimes(2)
157
+ expect(selector.mock.calls[1][0]).toBe(firstContext)
158
+
159
+ // After state change, new context is created
160
+ state.set('update1', {count: 1})
161
+ expect(source.getCurrent()).toBe(1)
162
+ expect(selector).toHaveBeenCalledTimes(3)
163
+ const secondContext = selector.mock.calls[2][0]
164
+ expect(secondContext).not.toBe(firstContext)
165
+
166
+ // Another call with same state reuses new context
167
+ expect(source.getCurrent()).toBe(1)
168
+ expect(selector).toHaveBeenCalledTimes(4)
169
+ expect(selector.mock.calls[3][0]).toBe(secondContext)
170
+
171
+ // State change again, new context
172
+ state.set('update2', {count: 2})
173
+ expect(source.getCurrent()).toBe(2)
174
+ expect(selector).toHaveBeenCalledTimes(5)
175
+ const thirdContext = selector.mock.calls[4][0]
176
+ expect(thirdContext).not.toBe(secondContext)
177
+ })
178
+
179
+ // New test: distinct contexts for same state with different instance
180
+ it('should create distinct contexts for same state with different instance', () => {
181
+ const secondInstance = createSanityInstance({projectId: 'test2', dataset: 'test2'})
182
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
183
+
184
+ const source1 = createStateSourceAction(selector)({state, instance})
185
+ source1.getCurrent()
186
+
187
+ const source2 = createStateSourceAction(selector)({state, instance: secondInstance})
188
+ source2.getCurrent()
189
+
190
+ const context1 = selector.mock.calls[0][0]
191
+ const context2 = selector.mock.calls[1][0]
192
+ expect(context1).not.toBe(context2)
193
+ expect(context1.instance).toBe(instance)
194
+ expect(context2.instance).toBe(secondInstance)
195
+ })
196
+ })
@@ -0,0 +1,260 @@
1
+ import {distinctUntilChanged, map, Observable, share, skip} from 'rxjs'
2
+
3
+ import {type StoreAction} from './createActionBinder'
4
+ import {type SanityInstance} from './createSanityInstance'
5
+ import {type StoreContext} from './defineStore'
6
+
7
+ /**
8
+ * Represents a reactive state source that provides synchronized access to store data
9
+ *
10
+ * @remarks
11
+ * Designed to work with React's useSyncExternalStore hook. Provides three ways to access data:
12
+ * 1. `getCurrent()` for synchronous current value access
13
+ * 2. `subscribe()` for imperative change notifications
14
+ * 3. `observable` for reactive stream access
15
+ *
16
+ * @public
17
+ */
18
+ export interface StateSource<T> {
19
+ /**
20
+ * Subscribes to state changes with optional callback
21
+ * @param onStoreChanged - Called whenever relevant state changes occur
22
+ * @returns Unsubscribe function to clean up the subscription
23
+ */
24
+ subscribe: (onStoreChanged?: () => void) => () => void
25
+
26
+ /**
27
+ * Gets the current derived state value
28
+ *
29
+ * @remarks
30
+ * Safe to call without subscription. Will always return the latest value
31
+ * based on the current store state and selector parameters.
32
+ */
33
+ getCurrent: () => T
34
+
35
+ /**
36
+ * Observable stream of state values
37
+ *
38
+ * @remarks
39
+ * Shares a single underlying subscription between all observers. Emits:
40
+ * - Immediately with current value on subscription
41
+ * - On every relevant state change
42
+ * - Errors if selector throws
43
+ */
44
+ observable: Observable<T>
45
+ }
46
+
47
+ /**
48
+ * Context passed to selectors when deriving state
49
+ *
50
+ * @remarks
51
+ * Provides access to both the current state value and the Sanity instance,
52
+ * allowing selectors to use configuration values when computing derived state.
53
+ * The context is memoized for each state object and instance combination
54
+ * to optimize performance and prevent unnecessary recalculations.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * // Using both state and instance in a selector (psuedo example)
59
+ * const getUserByProjectId = createStateSourceAction(
60
+ * ({ state, instance }: SelectorContext<UsersState>, options?: ProjectHandle) => {
61
+ * const allUsers = state.users
62
+ * const projectId = options?.projectId ?? instance.config.projectId
63
+ * return allUsers.filter(user => user.projectId === projectId)
64
+ * }
65
+ * )
66
+ * ```
67
+ */
68
+ export interface SelectorContext<TState> {
69
+ /**
70
+ * The current state object from the store
71
+ */
72
+ state: TState
73
+
74
+ /**
75
+ * The Sanity instance associated with this state
76
+ */
77
+ instance: SanityInstance
78
+ }
79
+
80
+ /**
81
+ * Function type for selecting derived state from store state and parameters
82
+ * @public
83
+ */
84
+ export type Selector<TState, TParams extends unknown[], TReturn> = (
85
+ context: SelectorContext<TState>,
86
+ ...params: TParams
87
+ ) => TReturn
88
+
89
+ /**
90
+ * Configuration options for creating a state source action
91
+ */
92
+ interface StateSourceOptions<TState, TParams extends unknown[], TReturn> {
93
+ /**
94
+ * Selector function that derives the desired value from store state
95
+ *
96
+ * @remarks
97
+ * Will be called on every store change. Should be pure function.
98
+ * Thrown errors will propagate to observable subscribers.
99
+ */
100
+ selector: Selector<TState, TParams, TReturn>
101
+
102
+ /**
103
+ * Optional setup/cleanup handler for subscriptions
104
+ *
105
+ * @param context - Store context containing state and instance
106
+ * @param params - Action parameters provided during invocation
107
+ * @returns Optional cleanup function called when subscription ends
108
+ */
109
+ onSubscribe?: (context: StoreContext<TState>, ...params: TParams) => void | (() => void)
110
+
111
+ /**
112
+ * Equality function to prevent unnecessary updates
113
+ */
114
+ isEqual?: (prev: TReturn, curr: TReturn) => boolean
115
+ }
116
+
117
+ /**
118
+ * Creates a state source action that generates StateSource instances
119
+ *
120
+ * @remarks
121
+ * The returned action can be bound to a store using createActionBinder.
122
+ * When invoked, returns a StateSource that stays synchronized with the store.
123
+ *
124
+ * Key performance features:
125
+ * - Memoizes selector contexts to prevent redundant object creation
126
+ * - Only runs selectors when the underlying state changes
127
+ *
128
+ * For complex data transformations, consider using memoized selectors
129
+ * (like those from Reselect) to prevent expensive recalculations.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * // Create a simple counter source
134
+ * const getCount = createStateSourceAction(({state}: SelectorContext<CounterState>) => state.count)
135
+ * ```
136
+ *
137
+ * @example
138
+ * ```ts
139
+ * // Create a parameterized source with setup/cleanup
140
+ * const getItem = createStateSourceAction({
141
+ * selector: ({state}, index: number) => state.items[index],
142
+ * onSubscribe: (context, index) => {
143
+ * trackItemSubscription(index)
144
+ * return () => untrackItem(index)
145
+ * }
146
+ * })
147
+ * ```
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * // Binding a state source to a specific store
152
+ * const documentStore = defineStore<DocumentState>({
153
+ * name: 'Documents',
154
+ * getInitialState: () => ({ documents: {} }),
155
+ * // ...
156
+ * })
157
+ *
158
+ * const getDocument = bindActionByDataset(
159
+ * documentStore,
160
+ * createStateSourceAction(({state}, documentId: string) => state.documents[documentId])
161
+ * )
162
+ *
163
+ * // Usage
164
+ * const documentSource = getDocument(sanityInstance, 'doc123')
165
+ * const doc = documentSource.getCurrent()
166
+ * const subscription = documentSource.observable.subscribe(updatedDoc => {
167
+ * console.log('Document changed:', updatedDoc)
168
+ * })
169
+ * ```
170
+ */
171
+ export function createStateSourceAction<TState, TParams extends unknown[], TReturn>(
172
+ options: Selector<TState, TParams, TReturn> | StateSourceOptions<TState, TParams, TReturn>,
173
+ ): StoreAction<TState, TParams, StateSource<TReturn>> {
174
+ const selector = typeof options === 'function' ? options : options.selector
175
+ const subscribeHandler = options && 'onSubscribe' in options ? options.onSubscribe : undefined
176
+ const isEqual = options && 'isEqual' in options ? (options.isEqual ?? Object.is) : Object.is
177
+ const selectorContextCache = new WeakMap<
178
+ object,
179
+ WeakMap<SanityInstance, SelectorContext<TState>>
180
+ >()
181
+
182
+ /**
183
+ * The state source action implementation
184
+ * @param context - Store context providing access to state and instance
185
+ * @param params - Parameters provided when invoking the bound action
186
+ */
187
+ function stateSourceAction(context: StoreContext<TState>, ...params: TParams) {
188
+ const {state, instance} = context
189
+
190
+ const getCurrent = () => {
191
+ const currentState = state.get()
192
+ if (typeof currentState !== 'object' || currentState === null) {
193
+ throw new Error(
194
+ `Expected store state to be an object but got "${typeof currentState}" instead`,
195
+ )
196
+ }
197
+
198
+ let instanceCache = selectorContextCache.get(currentState)
199
+ if (!instanceCache) {
200
+ instanceCache = new WeakMap<SanityInstance, SelectorContext<TState>>()
201
+ selectorContextCache.set(currentState, instanceCache)
202
+ }
203
+ let selectorContext = instanceCache.get(instance)
204
+ if (!selectorContext) {
205
+ selectorContext = {state: currentState, instance}
206
+ instanceCache.set(instance, selectorContext)
207
+ }
208
+ return selector(selectorContext, ...params)
209
+ }
210
+
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)
215
+
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
+ })
231
+
232
+ return () => {
233
+ subscription.unsubscribe()
234
+ cleanup?.()
235
+ }
236
+ }
237
+
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
+ return {
253
+ getCurrent,
254
+ subscribe,
255
+ observable,
256
+ }
257
+ }
258
+
259
+ return stateSourceAction
260
+ }
@@ -0,0 +1,81 @@
1
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {createSanityInstance} from './createSanityInstance'
4
+ import {createStoreInstance} from './createStoreInstance'
5
+ import {type StoreDefinition} from './defineStore'
6
+
7
+ describe('createStoreInstance', () => {
8
+ let instance: ReturnType<typeof createSanityInstance>
9
+
10
+ beforeEach(() => {
11
+ // Mock crypto for predictable instance IDs
12
+ vi.stubGlobal('crypto', {
13
+ randomUUID: () => 'test-uuid-1234',
14
+ })
15
+
16
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
17
+ })
18
+
19
+ const storeDef: StoreDefinition<{count: number}> = {
20
+ name: 'TestStore',
21
+ getInitialState: (inst) => ({
22
+ count: inst.config.projectId === 'test' ? 0 : -1,
23
+ }),
24
+ }
25
+
26
+ it('should create store instance with initial state', () => {
27
+ const store = createStoreInstance(instance, storeDef)
28
+ expect(store.state).toBeDefined()
29
+ })
30
+
31
+ it('should call getInitialState with Sanity instance', () => {
32
+ const getInitialState = vi.fn(() => ({count: 0}))
33
+ createStoreInstance(instance, {...storeDef, getInitialState})
34
+ expect(getInitialState).toHaveBeenCalledWith(instance)
35
+ })
36
+
37
+ it('should call initialize function with context', () => {
38
+ const initialize = vi.fn()
39
+
40
+ const store = createStoreInstance(instance, {
41
+ ...storeDef,
42
+ initialize,
43
+ })
44
+ expect(initialize).toHaveBeenCalledWith({
45
+ state: store.state,
46
+ instance,
47
+ })
48
+ })
49
+
50
+ it('should handle store disposal with cleanup function', () => {
51
+ const disposeMock = vi.fn()
52
+
53
+ const store = createStoreInstance(instance, {
54
+ ...storeDef,
55
+ initialize: () => disposeMock,
56
+ })
57
+ store.dispose()
58
+
59
+ expect(disposeMock).toHaveBeenCalledTimes(1)
60
+ expect(store.isDisposed()).toBe(true)
61
+ })
62
+
63
+ it('should handle disposal without initialize function', () => {
64
+ const store = createStoreInstance(instance, storeDef)
65
+ store.dispose()
66
+ expect(store.isDisposed()).toBe(true)
67
+ })
68
+
69
+ it('should prevent multiple disposals', () => {
70
+ const disposeMock = vi.fn()
71
+
72
+ const store = createStoreInstance(instance, {
73
+ ...storeDef,
74
+ initialize: () => disposeMock,
75
+ })
76
+ store.dispose()
77
+ store.dispose()
78
+
79
+ expect(disposeMock).toHaveBeenCalledTimes(1)
80
+ })
81
+ })
@@ -0,0 +1,80 @@
1
+ import {getEnv} from '../utils/getEnv'
2
+ import {type SanityInstance} from './createSanityInstance'
3
+ import {createStoreState, type StoreState} from './createStoreState'
4
+ import {type StoreDefinition} from './defineStore'
5
+
6
+ /**
7
+ * Represents a running instance of a store with its own state and lifecycle
8
+ *
9
+ * @remarks
10
+ * Each StoreInstance is tied to a specific SanityInstance, manages its own state,
11
+ * and can be independently disposed when no longer needed.
12
+ */
13
+ export interface StoreInstance<TState> {
14
+ /**
15
+ * Access to the reactive state container for this store instance
16
+ */
17
+ state: StoreState<TState>
18
+
19
+ /**
20
+ * Checks if this store instance has been disposed
21
+ * @returns Boolean indicating disposed state
22
+ */
23
+ isDisposed: () => void
24
+
25
+ /**
26
+ * Cleans up this store instance and runs any initialization cleanup functions
27
+ * @remarks Triggers the cleanup function returned from the initialize method
28
+ */
29
+ dispose: () => void
30
+ }
31
+
32
+ /**
33
+ * Creates a new instance of a store from a store definition
34
+ *
35
+ * @param instance - The Sanity instance this store will be associated with
36
+ * @param storeDefinition - The store definition containing initial state and initialization logic
37
+ * @returns A store instance with state management and lifecycle methods
38
+ *
39
+ * @remarks
40
+ * The store instance maintains its own state that is scoped to the given Sanity instance.
41
+ * If the store definition includes an initialize function, it will be called during
42
+ * instance creation, and its cleanup function will be called during disposal.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const counterStore = defineStore({
47
+ * name: 'Counter',
48
+ * getInitialState: () => ({ count: 0 }),
49
+ * initialize: ({state}) => {
50
+ * console.log('Counter store initialized')
51
+ * return () => console.log('Counter store disposed')
52
+ * }
53
+ * })
54
+ *
55
+ * const instance = createStoreInstance(sanityInstance, counterStore)
56
+ * // Later when done with the store:
57
+ * instance.dispose()
58
+ * ```
59
+ */
60
+ export function createStoreInstance<TState>(
61
+ instance: SanityInstance,
62
+ {name, getInitialState, initialize}: StoreDefinition<TState>,
63
+ ): StoreInstance<TState> {
64
+ const state = createStoreState(getInitialState(instance), {
65
+ enabled: !!getEnv('DEV'),
66
+ name: `${name}-${instance.config.projectId}.${instance.config.dataset}`,
67
+ })
68
+ const dispose = initialize?.({state, instance})
69
+ const disposed = {current: false}
70
+
71
+ return {
72
+ state,
73
+ dispose: () => {
74
+ if (disposed.current) return
75
+ disposed.current = true
76
+ dispose?.()
77
+ },
78
+ isDisposed: () => disposed.current,
79
+ }
80
+ }
@@ -0,0 +1,85 @@
1
+ import {firstValueFrom} from 'rxjs'
2
+ import {describe, expect, it, vi} from 'vitest'
3
+
4
+ import {createStoreState} from './createStoreState'
5
+
6
+ describe('createStoreState', () => {
7
+ it('should initialize with correct state', () => {
8
+ const store = createStoreState({count: 0, items: []})
9
+ expect(store.get()).toEqual({count: 0, items: []})
10
+ })
11
+
12
+ it('should update state with set()', () => {
13
+ const store = createStoreState({count: 0})
14
+ store.set('increment', {count: 1})
15
+ expect(store.get()).toEqual({count: 1})
16
+ })
17
+
18
+ it('should not update state if new value is identical', () => {
19
+ const store = createStoreState({count: 0})
20
+ const originalState = store.get()
21
+ store.set('noop', originalState)
22
+ expect(store.get()).toBe(originalState) // Reference equality check
23
+ })
24
+
25
+ it('should support functional updates', () => {
26
+ const store = createStoreState({count: 0})
27
+ store.set('increment', (prev) => ({count: prev.count + 1}))
28
+ expect(store.get()).toEqual({count: 1})
29
+ })
30
+
31
+ it('should emit initial state through observable', async () => {
32
+ const store = createStoreState({count: 42})
33
+ const value = await firstValueFrom(store.observable)
34
+ expect(value).toEqual({count: 42})
35
+ })
36
+
37
+ it('should emit state changes through observable', async () => {
38
+ const store = createStoreState({count: 0})
39
+ const emissions: number[] = []
40
+
41
+ const sub = store.observable.subscribe((state) => {
42
+ emissions.push(state.count)
43
+ })
44
+
45
+ store.set('inc1', {count: 1})
46
+ store.set('inc2', {count: 2})
47
+ sub.unsubscribe()
48
+
49
+ expect(emissions).toEqual([0, 1, 2])
50
+ })
51
+
52
+ it('should share observable between subscribers', () => {
53
+ const store = createStoreState({count: 0})
54
+ const sub1 = vi.fn()
55
+ const sub2 = vi.fn()
56
+
57
+ const subscription1 = store.observable.subscribe(sub1)
58
+ const subscription2 = store.observable.subscribe(sub2)
59
+
60
+ store.set('inc', {count: 1})
61
+
62
+ expect(sub1).toHaveBeenCalledWith({count: 1})
63
+ expect(sub2).toHaveBeenCalledWith({count: 1})
64
+
65
+ subscription1.unsubscribe()
66
+ subscription2.unsubscribe()
67
+ })
68
+
69
+ it('should handle multiple subscribers independently', () => {
70
+ const store = createStoreState({count: 0})
71
+ const sub1 = vi.fn()
72
+ const sub2 = vi.fn()
73
+
74
+ const subscription1 = store.observable.subscribe(sub1)
75
+ store.set('inc1', {count: 1})
76
+ const subscription2 = store.observable.subscribe(sub2)
77
+ store.set('inc2', {count: 2})
78
+
79
+ expect(sub1.mock.calls).toEqual([[{count: 0}], [{count: 1}], [{count: 2}]])
80
+ expect(sub2.mock.calls).toEqual([[{count: 1}], [{count: 2}]])
81
+
82
+ subscription1.unsubscribe()
83
+ subscription2.unsubscribe()
84
+ })
85
+ })