@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.
- package/dist/index.d.ts +441 -322
- package/dist/index.js +1685 -1481
- package/dist/index.js.map +1 -1
- package/package.json +13 -15
- package/src/_exports/index.ts +32 -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 +197 -91
- package/src/auth/refreshStampedToken.ts +170 -59
- 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 -238
- 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 +188 -72
- package/src/projection/getProjectionState.ts +92 -62
- package/src/projection/projectionQuery.test.ts +114 -12
- package/src/projection/projectionQuery.ts +75 -32
- package/src/projection/projectionStore.test.ts +13 -51
- package/src/projection/projectionStore.ts +6 -43
- package/src/projection/resolveProjection.test.ts +32 -127
- package/src/projection/resolveProjection.ts +16 -28
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +203 -116
- package/src/projection/subscribeToStateAndFetchBatches.ts +140 -85
- package/src/projection/types.ts +50 -0
- package/src/projection/util.ts +3 -1
- 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/utils/createGroqSearchFilter.test.ts +75 -0
- package/src/utils/createGroqSearchFilter.ts +85 -0
- package/src/{common/util.test.ts → utils/hashString.test.ts} +1 -1
- package/dist/index.cjs +0 -4888
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -2121
- 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,176 @@
|
|
|
1
|
+
import {type SanityConfig} from '../config/sanityConfig'
|
|
2
|
+
import {type SanityInstance} from './createSanityInstance'
|
|
3
|
+
import {createStoreInstance, type StoreInstance} from './createStoreInstance'
|
|
4
|
+
import {type StoreState} from './createStoreState'
|
|
5
|
+
import {type StoreContext, type StoreDefinition} from './defineStore'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Defines a store action that operates on a specific state type
|
|
9
|
+
*/
|
|
10
|
+
export type StoreAction<TState, TParams extends unknown[], TReturn> = (
|
|
11
|
+
context: StoreContext<TState>,
|
|
12
|
+
...params: TParams
|
|
13
|
+
) => TReturn
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Represents a store action that has been bound to a specific store instance
|
|
17
|
+
*/
|
|
18
|
+
export type BoundStoreAction<_TState, TParams extends unknown[], TReturn> = (
|
|
19
|
+
instance: SanityInstance,
|
|
20
|
+
...params: TParams
|
|
21
|
+
) => TReturn
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates an action binder function that uses the provided key function
|
|
25
|
+
* to determine how store instances are shared between Sanity instances
|
|
26
|
+
*
|
|
27
|
+
* @param keyFn - Function that generates a key from a Sanity config
|
|
28
|
+
* @returns A function that binds store actions to Sanity instances
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* Action binders determine how store instances are shared across multiple
|
|
32
|
+
* Sanity instances. The key function determines which instances share state.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* // Create a custom binder that uses a tenant ID for isolation
|
|
37
|
+
* const bindActionByTenant = createActionBinder(config => config.tenantId || 'default')
|
|
38
|
+
*
|
|
39
|
+
* // Use the custom binder with a store definition
|
|
40
|
+
* const getTenantUsers = bindActionByTenant(
|
|
41
|
+
* userStore,
|
|
42
|
+
* ({state}) => state.get().users
|
|
43
|
+
* )
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function createActionBinder(keyFn: (config: SanityConfig) => string) {
|
|
47
|
+
const instanceRegistry = new Map<string, Set<string>>()
|
|
48
|
+
const storeRegistry = new Map<string, StoreInstance<unknown>>()
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Binds a store action to a store definition
|
|
52
|
+
*
|
|
53
|
+
* @param storeDefinition - The store definition
|
|
54
|
+
* @param action - The action to bind
|
|
55
|
+
* @returns A function that executes the action with a Sanity instance
|
|
56
|
+
*/
|
|
57
|
+
return function bindAction<TState, TParams extends unknown[], TReturn>(
|
|
58
|
+
storeDefinition: StoreDefinition<TState>,
|
|
59
|
+
action: StoreAction<TState, TParams, TReturn>,
|
|
60
|
+
): BoundStoreAction<TState, TParams, TReturn> {
|
|
61
|
+
return function boundAction(instance: SanityInstance, ...params: TParams) {
|
|
62
|
+
const keySuffix = keyFn(instance.config)
|
|
63
|
+
const compositeKey = storeDefinition.name + (keySuffix ? `:${keySuffix}` : '')
|
|
64
|
+
|
|
65
|
+
// Get or create instance set for this composite key
|
|
66
|
+
let instances = instanceRegistry.get(compositeKey)
|
|
67
|
+
if (!instances) {
|
|
68
|
+
instances = new Set<string>()
|
|
69
|
+
instanceRegistry.set(compositeKey, instances)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Register instance for disposal tracking
|
|
73
|
+
if (!instances.has(instance.instanceId)) {
|
|
74
|
+
instances.add(instance.instanceId)
|
|
75
|
+
instance.onDispose(() => {
|
|
76
|
+
instances.delete(instance.instanceId)
|
|
77
|
+
|
|
78
|
+
// Clean up when last instance is disposed
|
|
79
|
+
if (instances.size === 0) {
|
|
80
|
+
storeRegistry.get(compositeKey)?.dispose()
|
|
81
|
+
storeRegistry.delete(compositeKey)
|
|
82
|
+
instanceRegistry.delete(compositeKey)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get or create store instance
|
|
88
|
+
let storeInstance = storeRegistry.get(compositeKey)
|
|
89
|
+
if (!storeInstance) {
|
|
90
|
+
storeInstance = createStoreInstance(instance, storeDefinition)
|
|
91
|
+
storeRegistry.set(compositeKey, storeInstance)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Execute action with store context
|
|
95
|
+
return action({instance, state: storeInstance.state as StoreState<TState>}, ...params)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Binds an action to a store that's scoped to a specific project and dataset
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* This creates actions that operate on state isolated to a specific projectId and dataset.
|
|
105
|
+
* Different project/dataset combinations will have separate states.
|
|
106
|
+
*
|
|
107
|
+
* @throws Error if projectId or dataset is missing from the Sanity instance config
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* // Define a store
|
|
112
|
+
* const documentStore = defineStore<DocumentState>({
|
|
113
|
+
* name: 'Document',
|
|
114
|
+
* getInitialState: () => ({ documents: {} }),
|
|
115
|
+
* // ...
|
|
116
|
+
* })
|
|
117
|
+
*
|
|
118
|
+
* // Create dataset-specific actions
|
|
119
|
+
* export const fetchDocument = bindActionByDataset(
|
|
120
|
+
* documentStore,
|
|
121
|
+
* ({instance, state}, documentId) => {
|
|
122
|
+
* // This state is isolated to the specific project/dataset
|
|
123
|
+
* // ...fetch logic...
|
|
124
|
+
* }
|
|
125
|
+
* )
|
|
126
|
+
*
|
|
127
|
+
* // Usage
|
|
128
|
+
* fetchDocument(sanityInstance, 'doc123')
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export const bindActionByDataset = createActionBinder(({projectId, dataset}) => {
|
|
132
|
+
if (!projectId || !dataset) {
|
|
133
|
+
throw new Error('This API requires a project ID and dataset configured.')
|
|
134
|
+
}
|
|
135
|
+
return `${projectId}.${dataset}`
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Binds an action to a global store that's shared across all Sanity instances
|
|
140
|
+
*
|
|
141
|
+
* @remarks
|
|
142
|
+
* This creates actions that operate on state shared globally across all Sanity instances.
|
|
143
|
+
* Use this for features like authentication where the state should be the same
|
|
144
|
+
* regardless of which project or dataset is being used.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* // Define a store
|
|
149
|
+
* const authStore = defineStore<AuthState>({
|
|
150
|
+
* name: 'Auth',
|
|
151
|
+
* getInitialState: () => ({
|
|
152
|
+
* user: null,
|
|
153
|
+
* isAuthenticated: false
|
|
154
|
+
* }),
|
|
155
|
+
* // ...
|
|
156
|
+
* })
|
|
157
|
+
*
|
|
158
|
+
* // Create global actions
|
|
159
|
+
* export const getCurrentUser = bindActionGlobally(
|
|
160
|
+
* authStore,
|
|
161
|
+
* ({state}) => state.get().user
|
|
162
|
+
* )
|
|
163
|
+
*
|
|
164
|
+
* export const login = bindActionGlobally(
|
|
165
|
+
* authStore,
|
|
166
|
+
* ({state, instance}, credentials) => {
|
|
167
|
+
* // Login logic that affects global state
|
|
168
|
+
* // ...
|
|
169
|
+
* }
|
|
170
|
+
* )
|
|
171
|
+
*
|
|
172
|
+
* // Usage with any instance
|
|
173
|
+
* getCurrentUser(sanityInstance)
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export const bindActionGlobally = createActionBinder(() => 'global')
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {createSanityInstance} from './createSanityInstance'
|
|
4
|
+
|
|
5
|
+
describe('createSanityInstance', () => {
|
|
6
|
+
it('should create an instance with a unique instanceId and given config', () => {
|
|
7
|
+
const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
|
|
8
|
+
expect(typeof instance.instanceId).toBe('string')
|
|
9
|
+
expect(instance.config).toEqual({projectId: 'proj1', dataset: 'ds1'})
|
|
10
|
+
expect(instance.isDisposed()).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should dispose an instance and call onDispose callbacks', () => {
|
|
14
|
+
const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
|
|
15
|
+
const callback = vi.fn()
|
|
16
|
+
instance.onDispose(callback)
|
|
17
|
+
instance.dispose()
|
|
18
|
+
expect(instance.isDisposed()).toBe(true)
|
|
19
|
+
expect(callback).toHaveBeenCalled()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should not call onDispose callbacks more than once when disposed multiple times', () => {
|
|
23
|
+
const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
|
|
24
|
+
const callback = vi.fn()
|
|
25
|
+
instance.onDispose(callback)
|
|
26
|
+
instance.dispose()
|
|
27
|
+
instance.dispose()
|
|
28
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should create a child instance with merged config and correct parent', () => {
|
|
32
|
+
const parent = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
|
|
33
|
+
const child = parent.createChild({dataset: 'ds2'})
|
|
34
|
+
expect(child.config).toEqual({projectId: 'proj1', dataset: 'ds2'})
|
|
35
|
+
expect(child.getParent()).toBe(parent)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should match an instance in the hierarchy using match', () => {
|
|
39
|
+
// three-level hierarchy
|
|
40
|
+
const grandparent = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
|
|
41
|
+
const parent = grandparent.createChild({projectId: 'proj2'})
|
|
42
|
+
const child = parent.createChild({dataset: 'ds2'})
|
|
43
|
+
|
|
44
|
+
expect(child.config).toEqual({projectId: 'proj2', dataset: 'ds2'})
|
|
45
|
+
expect(parent.config).toEqual({projectId: 'proj2', dataset: 'ds1'})
|
|
46
|
+
|
|
47
|
+
expect(child.match({dataset: 'ds2'})).toBe(child)
|
|
48
|
+
expect(child.match({projectId: 'proj2'})).toBe(child)
|
|
49
|
+
expect(child.match({projectId: 'proj1'})).toBe(grandparent)
|
|
50
|
+
expect(parent.match({projectId: 'proj1'})).toBe(grandparent)
|
|
51
|
+
expect(grandparent.match({projectId: 'proj1'})).toBe(grandparent)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should match `undefined` when the desired resource ID should not be set on an instance', () => {
|
|
55
|
+
const noProjectOrDataset = createSanityInstance()
|
|
56
|
+
const noDataset = noProjectOrDataset.createChild({projectId: 'proj1'})
|
|
57
|
+
const leaf = noDataset.createChild({dataset: 'ds1'})
|
|
58
|
+
|
|
59
|
+
// no keys means anything (in this case, self) will match
|
|
60
|
+
expect(leaf.match({})).toBe(leaf)
|
|
61
|
+
|
|
62
|
+
// `[resourceId]: undefined` means match an instance with no dataset set
|
|
63
|
+
expect(leaf.match({dataset: undefined})).toBe(noDataset)
|
|
64
|
+
expect(noDataset.match({dataset: undefined})).toBe(noDataset)
|
|
65
|
+
expect(leaf.match({projectId: undefined})).toBe(noProjectOrDataset)
|
|
66
|
+
expect(noDataset.match({projectId: undefined})).toBe(noProjectOrDataset)
|
|
67
|
+
expect(noProjectOrDataset.match({projectId: undefined})).toBe(noProjectOrDataset)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should return undefined when no match is found', () => {
|
|
71
|
+
const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
|
|
72
|
+
expect(instance.match({dataset: 'non-existent'})).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should inherit and merge auth config', () => {
|
|
76
|
+
const parent = createSanityInstance({
|
|
77
|
+
projectId: 'proj1',
|
|
78
|
+
dataset: 'ds1',
|
|
79
|
+
auth: {apiHost: 'api.sanity.work'},
|
|
80
|
+
})
|
|
81
|
+
const child = parent.createChild({auth: {token: 'my-token'}})
|
|
82
|
+
expect(child.config.auth).toEqual({apiHost: 'api.sanity.work', token: 'my-token'})
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {pick} from 'lodash-es'
|
|
2
|
+
|
|
3
|
+
import {type SanityConfig} from '../config/sanityConfig'
|
|
4
|
+
import {insecureRandomId} from '../utils/ids'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Represents a Sanity.io resource instance with its own configuration and lifecycle
|
|
8
|
+
* @remarks Instances form a hierarchy through parent/child relationships
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
export interface SanityInstance {
|
|
13
|
+
/**
|
|
14
|
+
* Unique identifier for this instance
|
|
15
|
+
* @remarks Generated using crypto.randomUUID()
|
|
16
|
+
*/
|
|
17
|
+
readonly instanceId: string
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolved configuration for this instance
|
|
21
|
+
* @remarks Merges values from parent instances where appropriate
|
|
22
|
+
*/
|
|
23
|
+
readonly config: SanityConfig
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Checks if the instance has been disposed
|
|
27
|
+
* @returns true if dispose() has been called
|
|
28
|
+
*/
|
|
29
|
+
isDisposed(): boolean
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Disposes the instance and cleans up associated resources
|
|
33
|
+
* @remarks Triggers all registered onDispose callbacks
|
|
34
|
+
*/
|
|
35
|
+
dispose(): void
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Registers a callback to be invoked when the instance is disposed
|
|
39
|
+
* @param cb - Callback to execute on disposal
|
|
40
|
+
* @returns Function to unsubscribe the callback
|
|
41
|
+
*/
|
|
42
|
+
onDispose(cb: () => void): () => void
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Gets the parent instance in the hierarchy
|
|
46
|
+
* @returns Parent instance or undefined if this is the root
|
|
47
|
+
*/
|
|
48
|
+
getParent(): SanityInstance | undefined
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a child instance with merged configuration
|
|
52
|
+
* @param config - Configuration to merge with parent values
|
|
53
|
+
* @remarks Child instances inherit parent configuration but can override values
|
|
54
|
+
*/
|
|
55
|
+
createChild(config: SanityConfig): SanityInstance
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Traverses the instance hierarchy to find the first instance whose configuration
|
|
59
|
+
* matches the given target config using a shallow comparison.
|
|
60
|
+
* @param targetConfig - A partial configuration object containing key-value pairs to match.
|
|
61
|
+
* @returns The first matching instance or undefined if no match is found.
|
|
62
|
+
*/
|
|
63
|
+
match(targetConfig: Partial<SanityConfig>): SanityInstance | undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Creates a new Sanity resource instance
|
|
68
|
+
* @param config - Configuration for the instance (optional)
|
|
69
|
+
* @returns A configured SanityInstance
|
|
70
|
+
* @remarks When creating child instances, configurations are merged with parent values
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
export function createSanityInstance(config: SanityConfig = {}): SanityInstance {
|
|
75
|
+
const instanceId = crypto.randomUUID()
|
|
76
|
+
const disposeListeners = new Map<string, () => void>()
|
|
77
|
+
const disposed = {current: false}
|
|
78
|
+
|
|
79
|
+
const instance: SanityInstance = {
|
|
80
|
+
instanceId,
|
|
81
|
+
config,
|
|
82
|
+
isDisposed: () => disposed.current,
|
|
83
|
+
dispose: () => {
|
|
84
|
+
if (disposed.current) return
|
|
85
|
+
disposed.current = true
|
|
86
|
+
disposeListeners.forEach((listener) => listener())
|
|
87
|
+
disposeListeners.clear()
|
|
88
|
+
},
|
|
89
|
+
onDispose: (cb) => {
|
|
90
|
+
const listenerId = insecureRandomId()
|
|
91
|
+
disposeListeners.set(listenerId, cb)
|
|
92
|
+
return () => {
|
|
93
|
+
disposeListeners.delete(listenerId)
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
getParent: () => undefined,
|
|
97
|
+
createChild: (next) =>
|
|
98
|
+
Object.assign(
|
|
99
|
+
createSanityInstance({
|
|
100
|
+
...config,
|
|
101
|
+
...next,
|
|
102
|
+
...(config.auth === next.auth
|
|
103
|
+
? config.auth
|
|
104
|
+
: config.auth && next.auth && {auth: {...config.auth, ...next.auth}}),
|
|
105
|
+
}),
|
|
106
|
+
{getParent: () => instance},
|
|
107
|
+
),
|
|
108
|
+
match: (targetConfig) => {
|
|
109
|
+
if (
|
|
110
|
+
Object.entries(pick(targetConfig, 'auth', 'projectId', 'dataset')).every(
|
|
111
|
+
([key, value]) => config[key as keyof SanityConfig] === value,
|
|
112
|
+
)
|
|
113
|
+
) {
|
|
114
|
+
return instance
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const parent = instance.getParent()
|
|
118
|
+
if (parent) return parent.match(targetConfig)
|
|
119
|
+
return undefined
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return instance
|
|
124
|
+
}
|
|
@@ -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
|
+
})
|