@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.
- package/dist/index.d.ts +428 -325
- package/dist/index.js +1618 -1553
- package/dist/index.js.map +1 -1
- package/package.json +6 -7
- package/src/_exports/index.ts +31 -30
- package/src/auth/authStore.test.ts +149 -104
- package/src/auth/authStore.ts +51 -100
- package/src/auth/handleAuthCallback.test.ts +67 -34
- package/src/auth/handleAuthCallback.ts +8 -7
- package/src/auth/logout.test.ts +61 -29
- package/src/auth/logout.ts +26 -28
- package/src/auth/refreshStampedToken.test.ts +9 -9
- package/src/auth/refreshStampedToken.ts +62 -56
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +5 -5
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +45 -47
- package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +4 -5
- package/src/auth/subscribeToStorageEventsAndSetToken.ts +22 -24
- package/src/client/clientStore.test.ts +131 -67
- package/src/client/clientStore.ts +117 -116
- package/src/comlink/controller/actions/destroyController.test.ts +38 -13
- package/src/comlink/controller/actions/destroyController.ts +11 -15
- package/src/comlink/controller/actions/getOrCreateChannel.test.ts +56 -27
- package/src/comlink/controller/actions/getOrCreateChannel.ts +37 -35
- package/src/comlink/controller/actions/getOrCreateController.test.ts +27 -16
- package/src/comlink/controller/actions/getOrCreateController.ts +23 -22
- package/src/comlink/controller/actions/releaseChannel.test.ts +37 -13
- package/src/comlink/controller/actions/releaseChannel.ts +22 -21
- package/src/comlink/controller/comlinkControllerStore.test.ts +65 -36
- package/src/comlink/controller/comlinkControllerStore.ts +44 -5
- package/src/comlink/node/actions/getOrCreateNode.test.ts +31 -15
- package/src/comlink/node/actions/getOrCreateNode.ts +30 -29
- package/src/comlink/node/actions/releaseNode.test.ts +75 -55
- package/src/comlink/node/actions/releaseNode.ts +19 -21
- package/src/comlink/node/comlinkNodeStore.test.ts +6 -11
- package/src/comlink/node/comlinkNodeStore.ts +22 -5
- package/src/config/authConfig.ts +79 -0
- package/src/config/sanityConfig.ts +48 -0
- package/src/datasets/datasets.test.ts +2 -2
- package/src/datasets/datasets.ts +18 -5
- package/src/document/actions.test.ts +22 -10
- package/src/document/actions.ts +44 -56
- package/src/document/applyDocumentActions.test.ts +96 -36
- package/src/document/applyDocumentActions.ts +140 -99
- package/src/document/documentStore.test.ts +103 -155
- package/src/document/documentStore.ts +247 -237
- package/src/document/listen.ts +56 -55
- package/src/document/patchOperations.ts +0 -43
- package/src/document/permissions.test.ts +25 -12
- package/src/document/permissions.ts +11 -4
- package/src/document/processActions.test.ts +41 -8
- package/src/document/reducers.test.ts +87 -16
- package/src/document/reducers.ts +2 -2
- package/src/document/sharedListener.test.ts +34 -16
- package/src/document/sharedListener.ts +33 -11
- package/src/preview/getPreviewState.test.ts +40 -39
- package/src/preview/getPreviewState.ts +68 -56
- package/src/preview/previewConstants.ts +43 -0
- package/src/preview/previewQuery.test.ts +1 -1
- package/src/preview/previewQuery.ts +4 -5
- package/src/preview/previewStore.test.ts +13 -58
- package/src/preview/previewStore.ts +7 -21
- package/src/preview/resolvePreview.test.ts +33 -104
- package/src/preview/resolvePreview.ts +11 -21
- package/src/preview/subscribeToStateAndFetchBatches.test.ts +96 -97
- package/src/preview/subscribeToStateAndFetchBatches.ts +85 -81
- package/src/preview/util.ts +1 -0
- package/src/project/project.test.ts +3 -3
- package/src/project/project.ts +28 -5
- package/src/projection/getProjectionState.test.ts +69 -49
- package/src/projection/getProjectionState.ts +42 -50
- package/src/projection/projectionQuery.ts +1 -1
- package/src/projection/projectionStore.test.ts +13 -51
- package/src/projection/projectionStore.ts +6 -18
- package/src/projection/resolveProjection.test.ts +32 -127
- package/src/projection/resolveProjection.ts +15 -28
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +105 -90
- package/src/projection/subscribeToStateAndFetchBatches.ts +94 -81
- package/src/projection/util.ts +2 -0
- package/src/projects/projects.test.ts +13 -4
- package/src/projects/projects.ts +6 -1
- package/src/query/queryStore.test.ts +10 -47
- package/src/query/queryStore.ts +151 -133
- package/src/query/queryStoreConstants.ts +2 -0
- package/src/store/createActionBinder.test.ts +153 -0
- package/src/store/createActionBinder.ts +176 -0
- package/src/store/createSanityInstance.test.ts +84 -0
- package/src/store/createSanityInstance.ts +124 -0
- package/src/store/createStateSourceAction.test.ts +196 -0
- package/src/store/createStateSourceAction.ts +260 -0
- package/src/store/createStoreInstance.test.ts +81 -0
- package/src/store/createStoreInstance.ts +80 -0
- package/src/store/createStoreState.test.ts +85 -0
- package/src/store/createStoreState.ts +92 -0
- package/src/store/defineStore.test.ts +18 -0
- package/src/store/defineStore.ts +81 -0
- package/src/users/reducers.test.ts +318 -0
- package/src/users/reducers.ts +88 -0
- package/src/users/types.ts +46 -4
- package/src/users/usersConstants.ts +4 -0
- package/src/users/usersStore.test.ts +350 -223
- package/src/users/usersStore.ts +285 -149
- package/src/utils/createFetcherStore.test.ts +6 -7
- package/src/utils/createFetcherStore.ts +150 -153
- package/src/{common/util.test.ts → utils/hashString.test.ts} +1 -1
- package/src/auth/fetchLoginUrls.test.ts +0 -163
- package/src/auth/fetchLoginUrls.ts +0 -74
- package/src/common/createLiveEventSubscriber.test.ts +0 -121
- package/src/common/createLiveEventSubscriber.ts +0 -55
- package/src/common/types.ts +0 -4
- package/src/instance/identity.test.ts +0 -46
- package/src/instance/identity.ts +0 -29
- package/src/instance/sanityInstance.test.ts +0 -77
- package/src/instance/sanityInstance.ts +0 -57
- package/src/instance/types.ts +0 -37
- package/src/preview/getPreviewProjection.ts +0 -45
- package/src/resources/README.md +0 -370
- package/src/resources/createAction.test.ts +0 -101
- package/src/resources/createAction.ts +0 -44
- package/src/resources/createResource.test.ts +0 -112
- package/src/resources/createResource.ts +0 -102
- package/src/resources/createStateSourceAction.test.ts +0 -114
- package/src/resources/createStateSourceAction.ts +0 -83
- package/src/resources/createStore.test.ts +0 -67
- package/src/resources/createStore.ts +0 -46
- package/src/store/createStore.test.ts +0 -108
- package/src/store/createStore.ts +0 -106
- /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
|
+
})
|