@sanity/sdk 0.0.0-chore-react-18-compat.1 → 0.0.0-chore-react-18-compat.3

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 (134) hide show
  1. package/dist/index.d.ts +441 -322
  2. package/dist/index.js +1685 -1481
  3. package/dist/index.js.map +1 -1
  4. package/package.json +13 -15
  5. package/src/_exports/index.ts +32 -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 +197 -91
  13. package/src/auth/refreshStampedToken.ts +170 -59
  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 -238
  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 +188 -72
  70. package/src/projection/getProjectionState.ts +92 -62
  71. package/src/projection/projectionQuery.test.ts +114 -12
  72. package/src/projection/projectionQuery.ts +75 -32
  73. package/src/projection/projectionStore.test.ts +13 -51
  74. package/src/projection/projectionStore.ts +6 -43
  75. package/src/projection/resolveProjection.test.ts +32 -127
  76. package/src/projection/resolveProjection.ts +16 -28
  77. package/src/projection/subscribeToStateAndFetchBatches.test.ts +203 -116
  78. package/src/projection/subscribeToStateAndFetchBatches.ts +140 -85
  79. package/src/projection/types.ts +50 -0
  80. package/src/projection/util.ts +3 -1
  81. package/src/projects/projects.test.ts +13 -4
  82. package/src/projects/projects.ts +6 -1
  83. package/src/query/queryStore.test.ts +10 -47
  84. package/src/query/queryStore.ts +151 -133
  85. package/src/query/queryStoreConstants.ts +2 -0
  86. package/src/store/createActionBinder.test.ts +153 -0
  87. package/src/store/createActionBinder.ts +176 -0
  88. package/src/store/createSanityInstance.test.ts +84 -0
  89. package/src/store/createSanityInstance.ts +124 -0
  90. package/src/store/createStateSourceAction.test.ts +196 -0
  91. package/src/store/createStateSourceAction.ts +260 -0
  92. package/src/store/createStoreInstance.test.ts +81 -0
  93. package/src/store/createStoreInstance.ts +80 -0
  94. package/src/store/createStoreState.test.ts +85 -0
  95. package/src/store/createStoreState.ts +92 -0
  96. package/src/store/defineStore.test.ts +18 -0
  97. package/src/store/defineStore.ts +81 -0
  98. package/src/users/reducers.test.ts +318 -0
  99. package/src/users/reducers.ts +88 -0
  100. package/src/users/types.ts +46 -4
  101. package/src/users/usersConstants.ts +4 -0
  102. package/src/users/usersStore.test.ts +350 -223
  103. package/src/users/usersStore.ts +285 -149
  104. package/src/utils/createFetcherStore.test.ts +6 -7
  105. package/src/utils/createFetcherStore.ts +150 -153
  106. package/src/utils/createGroqSearchFilter.test.ts +75 -0
  107. package/src/utils/createGroqSearchFilter.ts +85 -0
  108. package/src/{common/util.test.ts → utils/hashString.test.ts} +1 -1
  109. package/dist/index.cjs +0 -4888
  110. package/dist/index.cjs.map +0 -1
  111. package/dist/index.d.cts +0 -2121
  112. package/src/auth/fetchLoginUrls.test.ts +0 -163
  113. package/src/auth/fetchLoginUrls.ts +0 -74
  114. package/src/common/createLiveEventSubscriber.test.ts +0 -121
  115. package/src/common/createLiveEventSubscriber.ts +0 -55
  116. package/src/common/types.ts +0 -4
  117. package/src/instance/identity.test.ts +0 -46
  118. package/src/instance/identity.ts +0 -29
  119. package/src/instance/sanityInstance.test.ts +0 -77
  120. package/src/instance/sanityInstance.ts +0 -57
  121. package/src/instance/types.ts +0 -37
  122. package/src/preview/getPreviewProjection.ts +0 -45
  123. package/src/resources/README.md +0 -370
  124. package/src/resources/createAction.test.ts +0 -101
  125. package/src/resources/createAction.ts +0 -44
  126. package/src/resources/createResource.test.ts +0 -112
  127. package/src/resources/createResource.ts +0 -102
  128. package/src/resources/createStateSourceAction.test.ts +0 -114
  129. package/src/resources/createStateSourceAction.ts +0 -83
  130. package/src/resources/createStore.test.ts +0 -67
  131. package/src/resources/createStore.ts +0 -46
  132. package/src/store/createStore.test.ts +0 -108
  133. package/src/store/createStore.ts +0 -106
  134. /package/src/{common/util.ts → utils/hashString.ts} +0 -0
@@ -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
+ })
@@ -0,0 +1,92 @@
1
+ import {Observable} from 'rxjs'
2
+ import {devtools, type DevtoolsOptions} from 'zustand/middleware'
3
+ import {createStore} from 'zustand/vanilla'
4
+
5
+ /**
6
+ * Represents a reactive store state container with multiple access patterns
7
+ */
8
+ export interface StoreState<TState> {
9
+ /**
10
+ * Gets the current state value
11
+ *
12
+ * @remarks
13
+ * This is a direct synchronous accessor that doesn't trigger subscriptions
14
+ */
15
+ get: () => TState
16
+
17
+ /**
18
+ * Updates the store state
19
+ * @param name - Action name for devtools tracking
20
+ * @param updatedState - New state value or updater function
21
+ *
22
+ * @remarks
23
+ * When providing a partial object, previous top-level keys not included in
24
+ * the update will be preserved.
25
+ */
26
+ set: (name: string, updatedState: Partial<TState> | ((s: TState) => Partial<TState>)) => void
27
+
28
+ /**
29
+ * Observable stream of state changes
30
+ * @remarks
31
+ * - Emits immediately with current state on subscription
32
+ * - Shares underlying subscription between observers
33
+ * - Only emits when state reference changes
34
+ * - Completes when store is disposed
35
+ */
36
+ observable: Observable<TState>
37
+ }
38
+
39
+ /**
40
+ * Creates a reactive store state container with multiple access patterns
41
+ * @param initialState - Initial state value for the store
42
+ * @param devToolsOptions - Configuration for Zustand devtools integration
43
+ * @returns StoreState instance with get/set/observable interface
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * // Create a simple counter store
48
+ * const counterStore = createStoreState({ count: 0 });
49
+ *
50
+ * // Update state
51
+ * counterStore.set('increment', { count: 1 });
52
+ *
53
+ * // Observe changes
54
+ * counterStore.observable.subscribe(console.log);
55
+ * ```
56
+ *
57
+ * @remarks
58
+ * Uses Zustand for state management under the hood with RxJS for observable interface.
59
+ * Designed to work with both imperative and reactive programming patterns.
60
+ */
61
+ export function createStoreState<TState>(
62
+ initialState: TState,
63
+ devToolsOptions?: DevtoolsOptions,
64
+ ): StoreState<TState> {
65
+ // Create underlying Zustand store with devtools integration
66
+ const store = createStore<TState>()(devtools(() => initialState, devToolsOptions))
67
+
68
+ return {
69
+ get: store.getState,
70
+ set: (actionKey, updatedState) => {
71
+ const currentState = store.getState()
72
+ const nextState =
73
+ typeof updatedState === 'function' ? updatedState(currentState) : updatedState
74
+
75
+ // Optimization: Skip update if state reference remains the same
76
+ if (currentState !== nextState) {
77
+ store.setState(nextState, false, actionKey)
78
+ }
79
+ },
80
+ observable: new Observable((observer) => {
81
+ // Emit current state immediately on subscription
82
+ const emit = () => observer.next(store.getState())
83
+ emit()
84
+
85
+ // Subscribe to Zustand store changes
86
+ const unsubscribe = store.subscribe(emit)
87
+
88
+ // Cleanup when observable unsubscribed
89
+ return () => unsubscribe()
90
+ }),
91
+ }
92
+ }
@@ -0,0 +1,18 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {type SanityInstance} from './createSanityInstance'
4
+ import {defineStore, type StoreDefinition} from './defineStore'
5
+
6
+ describe('defineStore', () => {
7
+ it('should return the store definition unchanged', () => {
8
+ const storeDef: StoreDefinition<number> = {
9
+ name: 'TestStore',
10
+ getInitialState: () => 42,
11
+ }
12
+
13
+ const result = defineStore(storeDef)
14
+ expect(result).toBe(storeDef)
15
+ expect(result.name).toBe('TestStore')
16
+ expect(result.getInitialState({} as SanityInstance)).toBe(42)
17
+ })
18
+ })
@@ -0,0 +1,81 @@
1
+ import {type SanityInstance} from './createSanityInstance'
2
+ import {type StoreState} from './createStoreState'
3
+
4
+ /**
5
+ * Context object provided to store initialization functions
6
+ */
7
+ export interface StoreContext<TState> {
8
+ /**
9
+ * Sanity instance associated with this store
10
+ *
11
+ * @remarks
12
+ * Provides access to the Sanity configuration and instance lifecycle methods
13
+ */
14
+ instance: SanityInstance
15
+
16
+ /**
17
+ * Reactive store state management utilities
18
+ *
19
+ * @remarks
20
+ * Contains methods for getting/setting state and observing changes
21
+ */
22
+ state: StoreState<TState>
23
+ }
24
+
25
+ /**
26
+ * Defines the structure and behavior of a store
27
+ *
28
+ * @remarks
29
+ * Stores are isolated state containers that can be associated with Sanity instances.
30
+ * Each store definition creates a separate state instance per composite key.
31
+ */
32
+ export interface StoreDefinition<TState> {
33
+ /**
34
+ * Unique name for the store
35
+ *
36
+ * @remarks
37
+ * Used for debugging, devtools integration, and store identification
38
+ */
39
+ name: string
40
+
41
+ /**
42
+ * Creates the initial state for the store
43
+ * @param instance - Sanity instance the store is being created for
44
+ * @returns Initial state value
45
+ *
46
+ * @remarks
47
+ * Called when a new store instance is created. Can use Sanity instance
48
+ * configuration to determine initial state.
49
+ */
50
+ getInitialState: (instance: SanityInstance) => TState
51
+
52
+ /**
53
+ * Optional initialization function
54
+ * @param context - Store context with state and instance access
55
+ * @returns Optional cleanup function for store disposal
56
+ *
57
+ * @remarks
58
+ * Use this for:
59
+ * - Setting up event listeners
60
+ * - Initial data fetching
61
+ * - Connecting external services
62
+ *
63
+ * Return a cleanup function to:
64
+ * - Remove event listeners
65
+ * - Cancel pending operations
66
+ * - Dispose external connections
67
+ */
68
+ initialize?: (context: StoreContext<TState>) => (() => void) | undefined
69
+ }
70
+
71
+ /**
72
+ * Typescript helper function for creating store definitions
73
+ *
74
+ * @param storeDefinition - Configuration object defining the store
75
+ * @returns The finalized store definition
76
+ */
77
+ export function defineStore<TState>(
78
+ storeDefinition: StoreDefinition<TState>,
79
+ ): StoreDefinition<TState> {
80
+ return storeDefinition
81
+ }