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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/index.d.ts +428 -325
  2. package/dist/index.js +1618 -1553
  3. package/dist/index.js.map +1 -1
  4. package/package.json +6 -7
  5. package/src/_exports/index.ts +31 -30
  6. package/src/auth/authStore.test.ts +149 -104
  7. package/src/auth/authStore.ts +51 -100
  8. package/src/auth/handleAuthCallback.test.ts +67 -34
  9. package/src/auth/handleAuthCallback.ts +8 -7
  10. package/src/auth/logout.test.ts +61 -29
  11. package/src/auth/logout.ts +26 -28
  12. package/src/auth/refreshStampedToken.test.ts +9 -9
  13. package/src/auth/refreshStampedToken.ts +62 -56
  14. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +5 -5
  15. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +45 -47
  16. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +4 -5
  17. package/src/auth/subscribeToStorageEventsAndSetToken.ts +22 -24
  18. package/src/client/clientStore.test.ts +131 -67
  19. package/src/client/clientStore.ts +117 -116
  20. package/src/comlink/controller/actions/destroyController.test.ts +38 -13
  21. package/src/comlink/controller/actions/destroyController.ts +11 -15
  22. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +56 -27
  23. package/src/comlink/controller/actions/getOrCreateChannel.ts +37 -35
  24. package/src/comlink/controller/actions/getOrCreateController.test.ts +27 -16
  25. package/src/comlink/controller/actions/getOrCreateController.ts +23 -22
  26. package/src/comlink/controller/actions/releaseChannel.test.ts +37 -13
  27. package/src/comlink/controller/actions/releaseChannel.ts +22 -21
  28. package/src/comlink/controller/comlinkControllerStore.test.ts +65 -36
  29. package/src/comlink/controller/comlinkControllerStore.ts +44 -5
  30. package/src/comlink/node/actions/getOrCreateNode.test.ts +31 -15
  31. package/src/comlink/node/actions/getOrCreateNode.ts +30 -29
  32. package/src/comlink/node/actions/releaseNode.test.ts +75 -55
  33. package/src/comlink/node/actions/releaseNode.ts +19 -21
  34. package/src/comlink/node/comlinkNodeStore.test.ts +6 -11
  35. package/src/comlink/node/comlinkNodeStore.ts +22 -5
  36. package/src/config/authConfig.ts +79 -0
  37. package/src/config/sanityConfig.ts +48 -0
  38. package/src/datasets/datasets.test.ts +2 -2
  39. package/src/datasets/datasets.ts +18 -5
  40. package/src/document/actions.test.ts +22 -10
  41. package/src/document/actions.ts +44 -56
  42. package/src/document/applyDocumentActions.test.ts +96 -36
  43. package/src/document/applyDocumentActions.ts +140 -99
  44. package/src/document/documentStore.test.ts +103 -155
  45. package/src/document/documentStore.ts +247 -237
  46. package/src/document/listen.ts +56 -55
  47. package/src/document/patchOperations.ts +0 -43
  48. package/src/document/permissions.test.ts +25 -12
  49. package/src/document/permissions.ts +11 -4
  50. package/src/document/processActions.test.ts +41 -8
  51. package/src/document/reducers.test.ts +87 -16
  52. package/src/document/reducers.ts +2 -2
  53. package/src/document/sharedListener.test.ts +34 -16
  54. package/src/document/sharedListener.ts +33 -11
  55. package/src/preview/getPreviewState.test.ts +40 -39
  56. package/src/preview/getPreviewState.ts +68 -56
  57. package/src/preview/previewConstants.ts +43 -0
  58. package/src/preview/previewQuery.test.ts +1 -1
  59. package/src/preview/previewQuery.ts +4 -5
  60. package/src/preview/previewStore.test.ts +13 -58
  61. package/src/preview/previewStore.ts +7 -21
  62. package/src/preview/resolvePreview.test.ts +33 -104
  63. package/src/preview/resolvePreview.ts +11 -21
  64. package/src/preview/subscribeToStateAndFetchBatches.test.ts +96 -97
  65. package/src/preview/subscribeToStateAndFetchBatches.ts +85 -81
  66. package/src/preview/util.ts +1 -0
  67. package/src/project/project.test.ts +3 -3
  68. package/src/project/project.ts +28 -5
  69. package/src/projection/getProjectionState.test.ts +69 -49
  70. package/src/projection/getProjectionState.ts +42 -50
  71. package/src/projection/projectionQuery.ts +1 -1
  72. package/src/projection/projectionStore.test.ts +13 -51
  73. package/src/projection/projectionStore.ts +6 -18
  74. package/src/projection/resolveProjection.test.ts +32 -127
  75. package/src/projection/resolveProjection.ts +15 -28
  76. package/src/projection/subscribeToStateAndFetchBatches.test.ts +105 -90
  77. package/src/projection/subscribeToStateAndFetchBatches.ts +94 -81
  78. package/src/projection/util.ts +2 -0
  79. package/src/projects/projects.test.ts +13 -4
  80. package/src/projects/projects.ts +6 -1
  81. package/src/query/queryStore.test.ts +10 -47
  82. package/src/query/queryStore.ts +151 -133
  83. package/src/query/queryStoreConstants.ts +2 -0
  84. package/src/store/createActionBinder.test.ts +153 -0
  85. package/src/store/createActionBinder.ts +176 -0
  86. package/src/store/createSanityInstance.test.ts +84 -0
  87. package/src/store/createSanityInstance.ts +124 -0
  88. package/src/store/createStateSourceAction.test.ts +196 -0
  89. package/src/store/createStateSourceAction.ts +260 -0
  90. package/src/store/createStoreInstance.test.ts +81 -0
  91. package/src/store/createStoreInstance.ts +80 -0
  92. package/src/store/createStoreState.test.ts +85 -0
  93. package/src/store/createStoreState.ts +92 -0
  94. package/src/store/defineStore.test.ts +18 -0
  95. package/src/store/defineStore.ts +81 -0
  96. package/src/users/reducers.test.ts +318 -0
  97. package/src/users/reducers.ts +88 -0
  98. package/src/users/types.ts +46 -4
  99. package/src/users/usersConstants.ts +4 -0
  100. package/src/users/usersStore.test.ts +350 -223
  101. package/src/users/usersStore.ts +285 -149
  102. package/src/utils/createFetcherStore.test.ts +6 -7
  103. package/src/utils/createFetcherStore.ts +150 -153
  104. package/src/{common/util.test.ts → utils/hashString.test.ts} +1 -1
  105. package/src/auth/fetchLoginUrls.test.ts +0 -163
  106. package/src/auth/fetchLoginUrls.ts +0 -74
  107. package/src/common/createLiveEventSubscriber.test.ts +0 -121
  108. package/src/common/createLiveEventSubscriber.ts +0 -55
  109. package/src/common/types.ts +0 -4
  110. package/src/instance/identity.test.ts +0 -46
  111. package/src/instance/identity.ts +0 -29
  112. package/src/instance/sanityInstance.test.ts +0 -77
  113. package/src/instance/sanityInstance.ts +0 -57
  114. package/src/instance/types.ts +0 -37
  115. package/src/preview/getPreviewProjection.ts +0 -45
  116. package/src/resources/README.md +0 -370
  117. package/src/resources/createAction.test.ts +0 -101
  118. package/src/resources/createAction.ts +0 -44
  119. package/src/resources/createResource.test.ts +0 -112
  120. package/src/resources/createResource.ts +0 -102
  121. package/src/resources/createStateSourceAction.test.ts +0 -114
  122. package/src/resources/createStateSourceAction.ts +0 -83
  123. package/src/resources/createStore.test.ts +0 -67
  124. package/src/resources/createStore.ts +0 -46
  125. package/src/store/createStore.test.ts +0 -108
  126. package/src/store/createStore.ts +0 -106
  127. /package/src/{common/util.ts → utils/hashString.ts} +0 -0
@@ -0,0 +1,153 @@
1
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {bindActionByDataset, bindActionGlobally, createActionBinder} from './createActionBinder'
4
+ import {createSanityInstance} from './createSanityInstance'
5
+ import {createStoreInstance} from './createStoreInstance'
6
+
7
+ // Mock store instance creation for testing
8
+ vi.mock('./createStoreInstance', () => ({
9
+ createStoreInstance: vi.fn(() => ({state: {counter: 0}, dispose: vi.fn()})),
10
+ }))
11
+ beforeEach(() => vi.mocked(createStoreInstance).mockClear())
12
+
13
+ describe('createActionBinder', () => {
14
+ it('should bind an action and call it with correct context and parameters, using caching', () => {
15
+ const binder = createActionBinder(() => '')
16
+ const storeDefinition = {
17
+ name: 'TestStore',
18
+ getInitialState: () => ({counter: 0}),
19
+ }
20
+ // Action that increments counter by given value
21
+ const action = vi.fn((context, increment: number) => {
22
+ context.state.counter += increment
23
+ return context.state.counter
24
+ })
25
+ const boundAction = binder(storeDefinition, action)
26
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
27
+
28
+ // First call creates store instance
29
+ const result1 = boundAction(instance, 5)
30
+ expect(result1).toBe(5)
31
+ // Second call reuses cached store
32
+ const result2 = boundAction(instance, 5)
33
+ expect(result2).toBe(10)
34
+
35
+ expect(action).toHaveBeenCalledTimes(2)
36
+ expect(vi.mocked(createStoreInstance)).toHaveBeenCalledTimes(1)
37
+ })
38
+
39
+ it('should create separate store instances for different composite keys', () => {
40
+ const binder = createActionBinder(({projectId, dataset}) => `${projectId}.${dataset}`)
41
+ const storeDefinition = {
42
+ name: 'TestStore',
43
+ getInitialState: () => ({counter: 0}),
44
+ }
45
+ const action = vi.fn((context, val: number) => {
46
+ context.state.counter += val
47
+ return context.state.counter
48
+ })
49
+ const boundAction = binder(storeDefinition, action)
50
+ const instanceA = createSanityInstance({projectId: 'p1', dataset: 'd1'})
51
+ const instanceB = createSanityInstance({projectId: 'p2', dataset: 'd2'})
52
+
53
+ const resultA = boundAction(instanceA, 3)
54
+ const resultB = boundAction(instanceB, 4)
55
+
56
+ expect(resultA).toBe(3)
57
+ expect(resultB).toBe(4)
58
+ expect(vi.mocked(createStoreInstance)).toHaveBeenCalledTimes(2)
59
+ })
60
+
61
+ it('should dispose the store instance when the last instance is disposed', () => {
62
+ const binder = createActionBinder(() => '')
63
+ const storeDefinition = {
64
+ name: 'TestStore',
65
+ getInitialState: () => ({counter: 0}),
66
+ }
67
+ const action = vi.fn((context) => context.state.counter)
68
+ const boundAction = binder(storeDefinition, action)
69
+ const instance1 = createSanityInstance({projectId: 'p1', dataset: 'd1'})
70
+ const instance2 = createSanityInstance({projectId: 'p1', dataset: 'd1'})
71
+
72
+ // Call action on both instances
73
+ boundAction(instance1)
74
+ boundAction(instance2)
75
+ expect(vi.mocked(createStoreInstance)).toHaveBeenCalledTimes(1)
76
+
77
+ const [{value: storeInstance}] = vi.mocked(createStoreInstance).mock.results
78
+ expect(storeInstance).toBeDefined()
79
+
80
+ // First disposal shouldn't trigger store disposal
81
+ instance1.dispose()
82
+ expect(storeInstance.dispose).not.toHaveBeenCalled()
83
+
84
+ // Last disposal should trigger store disposal
85
+ instance2.dispose()
86
+ expect(storeInstance.dispose).toHaveBeenCalledTimes(1)
87
+ })
88
+ })
89
+
90
+ describe('bindActionByDataset', () => {
91
+ it('should work correctly when projectId and dataset are provided', () => {
92
+ const storeDefinition = {
93
+ name: 'DSStore',
94
+ getInitialState: () => ({counter: 0}),
95
+ }
96
+ const action = vi.fn((_context, value: string) => value)
97
+ const boundAction = bindActionByDataset(storeDefinition, action)
98
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
99
+ const result = boundAction(instance, 'hello')
100
+ expect(result).toBe('hello')
101
+ })
102
+
103
+ it('should throw an error if projectId or dataset is missing', () => {
104
+ const storeDefinition = {
105
+ name: 'DSStore',
106
+ getInitialState: () => ({counter: 0}),
107
+ }
108
+ const action = vi.fn((_context) => 'fail')
109
+ const boundAction = bindActionByDataset(storeDefinition, action)
110
+ // Instance with missing dataset
111
+ const instance = createSanityInstance({projectId: 'proj1', dataset: ''})
112
+ expect(() => boundAction(instance)).toThrow(
113
+ 'This API requires a project ID and dataset configured.',
114
+ )
115
+ })
116
+ })
117
+
118
+ describe('bindActionGlobally', () => {
119
+ it('should work correctly ignoring config in key generation', () => {
120
+ const storeDefinition = {
121
+ name: 'GlobalStore',
122
+ getInitialState: () => ({counter: 0}),
123
+ }
124
+ const action = vi.fn((_context, x: number) => x)
125
+ const boundAction = bindActionGlobally(storeDefinition, action)
126
+
127
+ // Create instances with different configs
128
+ const instance1 = createSanityInstance({projectId: 'any', dataset: 'any'})
129
+ const instance2 = createSanityInstance({projectId: 'different', dataset: 'config'})
130
+
131
+ // Both instances should use the same store
132
+ const result1 = boundAction(instance1, 42)
133
+ const result2 = boundAction(instance2, 99)
134
+
135
+ expect(result1).toBe(42)
136
+ expect(result2).toBe(99)
137
+
138
+ // Verify single store instance used
139
+ expect(vi.mocked(createStoreInstance)).toHaveBeenCalledTimes(1)
140
+
141
+ // Verify action called with correct arguments
142
+ expect(action).toHaveBeenNthCalledWith(1, expect.anything(), 42)
143
+ expect(action).toHaveBeenNthCalledWith(2, expect.anything(), 99)
144
+
145
+ // Test disposal tracking
146
+ const [{value: storeInstance}] = vi.mocked(createStoreInstance).mock.results
147
+ instance1.dispose()
148
+ expect(storeInstance.dispose).not.toHaveBeenCalled()
149
+
150
+ instance2.dispose()
151
+ expect(storeInstance.dispose).toHaveBeenCalledTimes(1)
152
+ })
153
+ })
@@ -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
+ }