@sanity/sdk 2.9.0 → 2.10.0
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/_chunks-dts/utils.d.ts +105 -51
- package/dist/_chunks-es/createGroqSearchFilter.js +131 -54
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/_exports/_internal.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +119 -73
- package/dist/index.js.map +1 -1
- package/package.json +8 -10
- package/src/_exports/index.ts +8 -0
- package/src/client/clientStore.test.ts +30 -30
- package/src/client/clientStore.ts +47 -47
- package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
- package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
- package/src/config/sanityConfig.ts +72 -12
- package/src/document/applyDocumentActions.test.ts +7 -7
- package/src/document/applyDocumentActions.ts +5 -5
- package/src/document/documentStore.test.ts +68 -62
- package/src/document/documentStore.ts +36 -36
- package/src/document/processActions.ts +2 -2
- package/src/document/reducers.ts +4 -4
- package/src/document/sharedListener.ts +7 -7
- package/src/presence/bifurTransport.test.ts +46 -6
- package/src/presence/bifurTransport.ts +13 -1
- package/src/presence/presenceStore.test.ts +96 -0
- package/src/presence/presenceStore.ts +96 -24
- package/src/preview/getPreviewState.ts +1 -1
- package/src/preview/previewProjectionUtils.test.ts +4 -4
- package/src/preview/previewProjectionUtils.ts +7 -7
- package/src/preview/resolvePreview.ts +5 -1
- package/src/projection/getProjectionState.ts +4 -4
- package/src/projection/projectionStore.test.ts +2 -2
- package/src/projection/resolveProjection.ts +2 -2
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
- package/src/projection/subscribeToStateAndFetchBatches.ts +12 -11
- package/src/query/queryStore.test.ts +12 -12
- package/src/query/queryStore.ts +10 -10
- package/src/query/reducers.ts +3 -3
- package/src/releases/getPerspectiveState.ts +5 -5
- package/src/releases/releasesStore.test.ts +6 -6
- package/src/releases/releasesStore.ts +9 -9
- package/src/store/createActionBinder.test.ts +31 -31
- package/src/store/createActionBinder.ts +43 -38
- package/src/store/createSanityInstance.ts +2 -3
- package/src/users/reducers.ts +3 -4
- package/src/utils/createFetcherStore.ts +6 -4
- package/src/utils/isImportError.test.ts +72 -0
- package/src/utils/isImportError.ts +34 -0
- package/src/utils/object.test.ts +95 -0
- package/src/utils/object.ts +142 -0
|
@@ -2,10 +2,10 @@ import {type ClientPerspective} from '@sanity/client'
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type DatasetHandle,
|
|
5
|
-
type
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
type DocumentResource,
|
|
6
|
+
isCanvasResource,
|
|
7
|
+
isDatasetResource,
|
|
8
|
+
isMediaLibraryResource,
|
|
9
9
|
type ReleasePerspective,
|
|
10
10
|
} from '../config/sanityConfig'
|
|
11
11
|
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
@@ -14,14 +14,14 @@ import {createStoreInstance, type StoreInstance} from './createStoreInstance'
|
|
|
14
14
|
import {type StoreState} from './createStoreState'
|
|
15
15
|
import {type StoreContext, type StoreDefinition} from './defineStore'
|
|
16
16
|
|
|
17
|
-
export interface
|
|
17
|
+
export interface BoundResourceKey {
|
|
18
18
|
name: string
|
|
19
|
-
|
|
19
|
+
resource: DocumentResource
|
|
20
20
|
}
|
|
21
|
-
export interface BoundPerspectiveKey extends
|
|
21
|
+
export interface BoundPerspectiveKey extends BoundResourceKey {
|
|
22
22
|
perspective: ClientPerspective | ReleasePerspective
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
interface BoundDatasetKey {
|
|
25
25
|
name: string
|
|
26
26
|
projectId: string
|
|
27
27
|
dataset: string
|
|
@@ -166,21 +166,24 @@ export const bindActionByDataset = createActionBinder<
|
|
|
166
166
|
return {name: `${projectId}.${dataset}`, projectId, dataset}
|
|
167
167
|
})
|
|
168
168
|
|
|
169
|
-
const
|
|
169
|
+
const createResourceKey = (
|
|
170
|
+
instance: SanityInstance,
|
|
171
|
+
resource?: DocumentResource,
|
|
172
|
+
): BoundResourceKey => {
|
|
170
173
|
let name: string | undefined
|
|
171
|
-
let
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
name = `${
|
|
176
|
-
} else if (
|
|
177
|
-
name = `media-library:${
|
|
178
|
-
} else if (
|
|
179
|
-
name = `canvas:${
|
|
174
|
+
let resourceForKey: DocumentResource | undefined
|
|
175
|
+
if (resource) {
|
|
176
|
+
resourceForKey = resource
|
|
177
|
+
if (isDatasetResource(resource)) {
|
|
178
|
+
name = `${resource.projectId}.${resource.dataset}`
|
|
179
|
+
} else if (isMediaLibraryResource(resource)) {
|
|
180
|
+
name = `media-library:${resource.mediaLibraryId}`
|
|
181
|
+
} else if (isCanvasResource(resource)) {
|
|
182
|
+
name = `canvas:${resource.canvasId}`
|
|
180
183
|
} else {
|
|
181
|
-
throw new Error(`Received invalid
|
|
184
|
+
throw new Error(`Received invalid resource: ${JSON.stringify(resource)}`)
|
|
182
185
|
}
|
|
183
|
-
return {name,
|
|
186
|
+
return {name, resource: resourceForKey}
|
|
184
187
|
}
|
|
185
188
|
|
|
186
189
|
// TODO: remove reference to instance.config when we get to v3
|
|
@@ -188,30 +191,32 @@ const createSourceKey = (instance: SanityInstance, source?: DocumentSource): Bou
|
|
|
188
191
|
if (!projectId || !dataset) {
|
|
189
192
|
throw new Error('This API requires a project ID and dataset configured.')
|
|
190
193
|
}
|
|
191
|
-
return {name: `${projectId}.${dataset}`,
|
|
194
|
+
return {name: `${projectId}.${dataset}`, resource: {projectId, dataset}}
|
|
192
195
|
}
|
|
193
196
|
|
|
194
197
|
/**
|
|
195
|
-
* Binds an action to a store that's scoped to a specific document
|
|
198
|
+
* Binds an action to a store that's scoped to a specific document resource.
|
|
196
199
|
**/
|
|
197
|
-
export const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
200
|
+
export const bindActionByResource = createActionBinder<
|
|
201
|
+
BoundResourceKey,
|
|
202
|
+
// this implies resources is optional to keep backwards compatibility
|
|
203
|
+
// but in reality, we'll always pass a resource (since we'll defer to the instance until v3)
|
|
204
|
+
[{resource?: DocumentResource}, ...unknown[]]
|
|
205
|
+
>((instance, {resource}) => {
|
|
206
|
+
return createResourceKey(instance, resource)
|
|
202
207
|
})
|
|
203
208
|
|
|
204
209
|
/**
|
|
205
|
-
* Binds an action to a store that's scoped to a specific document
|
|
210
|
+
* Binds an action to a store that's scoped to a specific document resource and perspective.
|
|
206
211
|
*
|
|
207
212
|
* @remarks
|
|
208
|
-
* This creates actions that operate on state isolated to a specific document
|
|
209
|
-
* Different document
|
|
213
|
+
* This creates actions that operate on state isolated to a specific document resource and perspective.
|
|
214
|
+
* Different document resources and perspectives will have separate states.
|
|
210
215
|
*
|
|
211
216
|
* This is mostly useful for stores that do batch fetching operations, since the query store
|
|
212
217
|
* can isolate single queries by perspective.
|
|
213
218
|
*
|
|
214
|
-
* @throws Error if
|
|
219
|
+
* @throws Error if resource or perspective is missing from the Sanity instance config
|
|
215
220
|
*
|
|
216
221
|
* @example
|
|
217
222
|
* ```ts
|
|
@@ -222,11 +227,11 @@ export const bindActionBySource = createActionBinder<
|
|
|
222
227
|
* // ...
|
|
223
228
|
* })
|
|
224
229
|
*
|
|
225
|
-
* // Create
|
|
226
|
-
* export const fetchDocuments =
|
|
230
|
+
* // Create resource-and-perspective-specific actions
|
|
231
|
+
* export const fetchDocuments = bindActionByResourceAndPerspective(
|
|
227
232
|
* documentStore,
|
|
228
233
|
* ({instance, state}, documentId) => {
|
|
229
|
-
* // This state is isolated to the specific document
|
|
234
|
+
* // This state is isolated to the specific document resource and perspective
|
|
230
235
|
* // ...fetch logic...
|
|
231
236
|
* }
|
|
232
237
|
* )
|
|
@@ -235,11 +240,11 @@ export const bindActionBySource = createActionBinder<
|
|
|
235
240
|
* fetchDocument(sanityInstance, 'doc123')
|
|
236
241
|
* ```
|
|
237
242
|
*/
|
|
238
|
-
export const
|
|
243
|
+
export const bindActionByResourceAndPerspective = createActionBinder<
|
|
239
244
|
BoundPerspectiveKey,
|
|
240
245
|
[DatasetHandle, ...unknown[]]
|
|
241
246
|
>((instance, options): BoundPerspectiveKey => {
|
|
242
|
-
const {
|
|
247
|
+
const {resource, perspective} = options
|
|
243
248
|
// TODO: remove reference to instance.config.perspective when we get to v3
|
|
244
249
|
const utilizedPerspective = perspective ?? instance.config.perspective ?? 'drafts'
|
|
245
250
|
let perspectiveKey: string
|
|
@@ -251,11 +256,11 @@ export const bindActionBySourceAndPerspective = createActionBinder<
|
|
|
251
256
|
// "StackablePerspective", shouldn't be a common case, but just in case
|
|
252
257
|
perspectiveKey = JSON.stringify(utilizedPerspective)
|
|
253
258
|
}
|
|
254
|
-
const sourceKey =
|
|
259
|
+
const sourceKey = createResourceKey(instance, resource)
|
|
255
260
|
|
|
256
261
|
return {
|
|
257
262
|
name: `${sourceKey.name}:${perspectiveKey}`,
|
|
258
|
-
|
|
263
|
+
resource: sourceKey.resource,
|
|
259
264
|
perspective: utilizedPerspective,
|
|
260
265
|
}
|
|
261
266
|
})
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {pick} from 'lodash-es'
|
|
2
|
-
|
|
3
1
|
import {type SanityConfig} from '../config/sanityConfig'
|
|
4
2
|
import {insecureRandomId} from '../utils/ids'
|
|
5
3
|
import {createLogger, type InstanceContext} from '../utils/logger'
|
|
4
|
+
import {pickProperties} from '../utils/object'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Represents a Sanity.io resource instance with its own configuration and lifecycle
|
|
@@ -158,7 +157,7 @@ export function createSanityInstance(config: SanityConfig = {}): SanityInstance
|
|
|
158
157
|
},
|
|
159
158
|
match: (targetConfig) => {
|
|
160
159
|
if (
|
|
161
|
-
Object.entries(
|
|
160
|
+
Object.entries(pickProperties(targetConfig, ['auth', 'projectId', 'dataset'])).every(
|
|
162
161
|
([key, value]) => config[key as keyof SanityConfig] === value,
|
|
163
162
|
)
|
|
164
163
|
) {
|
package/src/users/reducers.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {omit} from 'lodash-es'
|
|
2
|
-
|
|
3
1
|
import {type SanityInstance} from '../store/createSanityInstance'
|
|
2
|
+
import {omitProperty} from '../utils/object'
|
|
4
3
|
import {type GetUsersOptions, type SanityUserResponse, type UsersStoreState} from './types'
|
|
5
4
|
import {DEFAULT_USERS_BATCH_SIZE} from './usersConstants'
|
|
6
5
|
|
|
@@ -48,7 +47,7 @@ export const removeSubscription =
|
|
|
48
47
|
const group = prev.users[key]
|
|
49
48
|
if (!group) return prev
|
|
50
49
|
const subscriptions = group.subscriptions.filter((id) => id !== subscriptionId)
|
|
51
|
-
if (!subscriptions.length) return {...prev, users:
|
|
50
|
+
if (!subscriptions.length) return {...prev, users: omitProperty(prev.users, key)}
|
|
52
51
|
return {...prev, users: {...prev.users, [key]: {...group, subscriptions}}}
|
|
53
52
|
}
|
|
54
53
|
|
|
@@ -83,7 +82,7 @@ export const cancelRequest =
|
|
|
83
82
|
const group = prev.users[key]
|
|
84
83
|
if (!group) return prev
|
|
85
84
|
if (group.subscriptions.length) return prev
|
|
86
|
-
return {...prev, users:
|
|
85
|
+
return {...prev, users: omitProperty(prev.users, key)}
|
|
87
86
|
}
|
|
88
87
|
|
|
89
88
|
export const initializeRequest =
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import {omit} from 'lodash-es'
|
|
2
1
|
import {asapScheduler, EMPTY, firstValueFrom, from, Observable} from 'rxjs'
|
|
3
2
|
import {
|
|
4
3
|
catchError,
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
} from '../store/createStateSourceAction'
|
|
24
23
|
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
25
24
|
import {insecureRandomId} from '../utils/ids'
|
|
25
|
+
import {omitProperty} from '../utils/object'
|
|
26
26
|
import {setCleanupTimeout} from './setCleanupTimeout'
|
|
27
27
|
|
|
28
28
|
interface CreateFetcherStoreOptions<TParams extends unknown[], TData> {
|
|
@@ -183,8 +183,10 @@ export function createFetcherStore<TParams extends unknown[], TData>({
|
|
|
183
183
|
stateByParams: {
|
|
184
184
|
...prev.stateByParams,
|
|
185
185
|
[entry.key]: {
|
|
186
|
-
...
|
|
187
|
-
...
|
|
186
|
+
...omitProperty(entry, 'error'),
|
|
187
|
+
...(prev.stateByParams[entry.key]
|
|
188
|
+
? omitProperty(prev.stateByParams[entry.key], 'error')
|
|
189
|
+
: {}),
|
|
188
190
|
data,
|
|
189
191
|
},
|
|
190
192
|
},
|
|
@@ -255,7 +257,7 @@ export function createFetcherStore<TParams extends unknown[], TData>({
|
|
|
255
257
|
|
|
256
258
|
const newSubs = (entry.subscriptions || []).filter((id) => id !== subscriptionId)
|
|
257
259
|
if (newSubs.length === 0) {
|
|
258
|
-
return {stateByParams:
|
|
260
|
+
return {stateByParams: omitProperty(prev.stateByParams, key)}
|
|
259
261
|
}
|
|
260
262
|
|
|
261
263
|
return {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {describe, expect, test} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {isImportError} from './isImportError'
|
|
4
|
+
|
|
5
|
+
describe('isImportError', () => {
|
|
6
|
+
test('returns false for non-Error values', () => {
|
|
7
|
+
expect(isImportError(null)).toBe(false)
|
|
8
|
+
expect(isImportError(undefined)).toBe(false)
|
|
9
|
+
expect(isImportError('Loading chunk 5 failed.')).toBe(false)
|
|
10
|
+
expect(isImportError({message: 'Loading chunk 5 failed.'})).toBe(false)
|
|
11
|
+
expect(isImportError(42)).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('returns false for unrelated Error instances', () => {
|
|
15
|
+
expect(isImportError(new Error('Something else went wrong'))).toBe(false)
|
|
16
|
+
expect(isImportError(new TypeError('Cannot read properties of undefined'))).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('detects webpack ChunkLoadError by name', () => {
|
|
20
|
+
const err = new Error('arbitrary message')
|
|
21
|
+
err.name = 'ChunkLoadError'
|
|
22
|
+
expect(isImportError(err)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('detects webpack numeric chunk failure messages', () => {
|
|
26
|
+
expect(isImportError(new Error('Loading chunk 5 failed.'))).toBe(true)
|
|
27
|
+
expect(
|
|
28
|
+
isImportError(new Error('Loading chunk 42 failed. (missing: https://x.com/42.abc.js)')),
|
|
29
|
+
).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('detects webpack named chunk failure messages', () => {
|
|
33
|
+
expect(isImportError(new Error('Loading chunk vendors-foo failed.'))).toBe(true)
|
|
34
|
+
expect(isImportError(new Error('Loading chunk react_vendors failed.'))).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('detects Vite "Failed to fetch dynamically imported module"', () => {
|
|
38
|
+
expect(
|
|
39
|
+
isImportError(
|
|
40
|
+
new TypeError(
|
|
41
|
+
'Failed to fetch dynamically imported module: https://example.com/assets/Home-abc123.js',
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('detects Firefox "error loading dynamically imported module"', () => {
|
|
48
|
+
expect(
|
|
49
|
+
isImportError(
|
|
50
|
+
new TypeError(
|
|
51
|
+
'error loading dynamically imported module: http://localhost:8080/src/views/Dashboard/index.vue',
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('detects Safari module-script failures with and without the "ing" suffix', () => {
|
|
58
|
+
expect(isImportError(new TypeError('Importing a module script failed.'))).toBe(true)
|
|
59
|
+
expect(isImportError(new TypeError('Import a module script failed.'))).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('detects Vite CSS preload failures', () => {
|
|
63
|
+
expect(isImportError(new Error('Unable to preload CSS for /assets/App-BBLnt7oG.css'))).toBe(
|
|
64
|
+
true,
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('matches case-insensitively', () => {
|
|
69
|
+
expect(isImportError(new Error('loading chunk 1 FAILED'))).toBe(true)
|
|
70
|
+
expect(isImportError(new Error('FAILED TO FETCH DYNAMICALLY IMPORTED MODULE'))).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when the given error looks like a dynamic-import or
|
|
3
|
+
* code-split chunk-loading failure.
|
|
4
|
+
*
|
|
5
|
+
* These errors typically surface when a user has a tab open against a
|
|
6
|
+
* previously-deployed version of an app and the JavaScript or CSS chunk
|
|
7
|
+
* filenames have since changed: a fresh deployment removes the hashed assets
|
|
8
|
+
* the open tab still references. Detecting them lets the SDK trigger an
|
|
9
|
+
* automatic reload so the user gets the new build without manual intervention.
|
|
10
|
+
*
|
|
11
|
+
* Recognized shapes (webpack ChunkLoadError, Vite "Failed to fetch
|
|
12
|
+
* dynamically imported module", Firefox "error loading dynamically imported
|
|
13
|
+
* module", Safari "Importing a module script failed", and Vite "Unable to
|
|
14
|
+
* preload CSS").
|
|
15
|
+
*
|
|
16
|
+
* @param error - The value to inspect. Anything that is not an Error
|
|
17
|
+
* instance returns false.
|
|
18
|
+
* @returns True if the error matches a known import/chunk-load failure.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export function isImportError(error: unknown): boolean {
|
|
23
|
+
if (!(error instanceof Error)) return false
|
|
24
|
+
if (error.name === 'ChunkLoadError') return true
|
|
25
|
+
|
|
26
|
+
const message = error.message || ''
|
|
27
|
+
return (
|
|
28
|
+
/Loading chunk [\w-]+ failed/i.test(message) ||
|
|
29
|
+
/Failed to fetch dynamically imported module/i.test(message) ||
|
|
30
|
+
/error loading dynamically imported module/i.test(message) ||
|
|
31
|
+
/Import(?:ing)? a module script failed/i.test(message) ||
|
|
32
|
+
/Unable to preload CSS/i.test(message)
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {isDeepEqual, isObject, omitProperty, pickProperties} from './object'
|
|
4
|
+
|
|
5
|
+
describe('object utils', () => {
|
|
6
|
+
describe('isObject', () => {
|
|
7
|
+
it('returns true for objects and false for primitives', () => {
|
|
8
|
+
expect(isObject({foo: 'bar'})).toBe(true)
|
|
9
|
+
expect(isObject(null)).toBe(false)
|
|
10
|
+
expect(isObject('hello')).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('omitProperty', () => {
|
|
15
|
+
it('removes a property from an object copy', () => {
|
|
16
|
+
expect(omitProperty({foo: 'bar', baz: 1}, 'foo')).toEqual({baz: 1})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns an empty object for undefined input', () => {
|
|
20
|
+
expect(omitProperty<{foo: string}, 'foo'>(undefined, 'foo')).toEqual({})
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('pickProperties', () => {
|
|
25
|
+
it('copies only the requested own properties', () => {
|
|
26
|
+
expect(pickProperties({foo: 'bar', baz: 1}, ['foo'])).toEqual({foo: 'bar'})
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('isDeepEqual', () => {
|
|
31
|
+
it('matches nested plain objects and arrays', () => {
|
|
32
|
+
expect(
|
|
33
|
+
isDeepEqual({foo: [{bar: 'baz'}], qux: {count: 2}}, {foo: [{bar: 'baz'}], qux: {count: 2}}),
|
|
34
|
+
).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns false for unequal nested plain objects and arrays', () => {
|
|
38
|
+
expect(
|
|
39
|
+
isDeepEqual(
|
|
40
|
+
{foo: [{bar: 'baz'}], qux: {count: 2}},
|
|
41
|
+
{foo: [{bar: 'nope'}], qux: {count: 2}},
|
|
42
|
+
),
|
|
43
|
+
).toBe(false)
|
|
44
|
+
expect(isDeepEqual([1, {foo: 'bar'}], [1, {foo: 'baz'}])).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('matches sets and maps with equal contents', () => {
|
|
48
|
+
expect(isDeepEqual(new Set([{foo: 'bar'}, 'baz']), new Set(['baz', {foo: 'bar'}]))).toBe(true)
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
isDeepEqual(
|
|
52
|
+
new Map<string, unknown>([
|
|
53
|
+
['foo', {bar: 'baz'}],
|
|
54
|
+
['count', 2],
|
|
55
|
+
]),
|
|
56
|
+
new Map<string, unknown>([
|
|
57
|
+
['foo', {bar: 'baz'}],
|
|
58
|
+
['count', 2],
|
|
59
|
+
]),
|
|
60
|
+
),
|
|
61
|
+
).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('compares dates by timestamp', () => {
|
|
65
|
+
expect(isDeepEqual(new Date('2024-01-01'), new Date('2024-01-01'))).toBe(true)
|
|
66
|
+
expect(isDeepEqual(new Date('2024-01-01'), new Date('2024-01-02'))).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('compares regular expressions by source and flags', () => {
|
|
70
|
+
expect(isDeepEqual(/foo/gi, /foo/gi)).toBe(true)
|
|
71
|
+
expect(isDeepEqual(/foo/g, /foo/i)).toBe(false)
|
|
72
|
+
expect(isDeepEqual(/foo/g, /bar/g)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns false for cross-shape mismatches', () => {
|
|
76
|
+
expect(isDeepEqual({foo: 'bar'}, ['foo', 'bar'] as unknown as {foo: string})).toBe(false)
|
|
77
|
+
expect(
|
|
78
|
+
isDeepEqual(
|
|
79
|
+
new Map<string, unknown>([['foo', 'bar']]) as unknown as object,
|
|
80
|
+
new Set(['foo', 'bar']) as unknown as object,
|
|
81
|
+
),
|
|
82
|
+
).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('treats non-plain objects as unequal unless they are the same reference', () => {
|
|
86
|
+
class Example {
|
|
87
|
+
constructor(public value: string) {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
expect(isDeepEqual(new Example('a'), new Example('a'))).toBe(false)
|
|
91
|
+
const instance = new Example('a')
|
|
92
|
+
expect(isDeepEqual(instance, instance)).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when the input is a non-null object.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export function isObject(value: unknown): value is object {
|
|
7
|
+
return typeof value === 'object' && value !== null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const hasOwn = (value: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(value, key)
|
|
11
|
+
|
|
12
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
13
|
+
if (!isObject(value)) return false
|
|
14
|
+
|
|
15
|
+
const prototype = Object.getPrototypeOf(value)
|
|
16
|
+
return prototype === Object.prototype || prototype === null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates a shallow copy without the provided property.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export function omitProperty<T extends object, K extends keyof T>(
|
|
25
|
+
value: T | null | undefined,
|
|
26
|
+
key: K,
|
|
27
|
+
): Omit<T, K> {
|
|
28
|
+
if (!value) return {} as Omit<T, K>
|
|
29
|
+
|
|
30
|
+
const {[key]: _omitted, ...rest} = value
|
|
31
|
+
return rest
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a shallow copy containing only the provided properties.
|
|
36
|
+
*
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
export function pickProperties<T extends object, K extends keyof T>(
|
|
40
|
+
value: T,
|
|
41
|
+
keys: readonly K[],
|
|
42
|
+
): Pick<T, K> {
|
|
43
|
+
const result = {} as Pick<T, K>
|
|
44
|
+
|
|
45
|
+
for (const key of keys) {
|
|
46
|
+
if (hasOwn(value, key)) {
|
|
47
|
+
result[key] = value[key]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const areSetsEqual = (left: Set<unknown>, right: Set<unknown>): boolean => {
|
|
55
|
+
if (left.size !== right.size) return false
|
|
56
|
+
|
|
57
|
+
const unmatched = [...right]
|
|
58
|
+
|
|
59
|
+
outer: for (const leftValue of left) {
|
|
60
|
+
for (let index = 0; index < unmatched.length; index++) {
|
|
61
|
+
if (isDeepEqual(leftValue, unmatched[index])) {
|
|
62
|
+
unmatched.splice(index, 1)
|
|
63
|
+
continue outer
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return unmatched.length === 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const areMapsEqual = (left: Map<unknown, unknown>, right: Map<unknown, unknown>): boolean => {
|
|
74
|
+
if (left.size !== right.size) return false
|
|
75
|
+
|
|
76
|
+
const unmatched = [...right.entries()]
|
|
77
|
+
|
|
78
|
+
outer: for (const [leftKey, leftValue] of left) {
|
|
79
|
+
for (let index = 0; index < unmatched.length; index++) {
|
|
80
|
+
const [rightKey, rightValue] = unmatched[index]
|
|
81
|
+
|
|
82
|
+
if (isDeepEqual(leftKey, rightKey) && isDeepEqual(leftValue, rightValue)) {
|
|
83
|
+
unmatched.splice(index, 1)
|
|
84
|
+
continue outer
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return unmatched.length === 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compares values deeply across the plain object, array, map, and set shapes used by the SDK.
|
|
96
|
+
* This helper is intended for acyclic SDK data structures and does not guard against circular
|
|
97
|
+
* references.
|
|
98
|
+
*
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
export function isDeepEqual<T>(left: T, right: T): boolean {
|
|
102
|
+
if (Object.is(left, right)) return true
|
|
103
|
+
|
|
104
|
+
if (!isObject(left) || !isObject(right)) return false
|
|
105
|
+
|
|
106
|
+
if (left instanceof Date && right instanceof Date) {
|
|
107
|
+
return left.getTime() === right.getTime()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (left instanceof RegExp && right instanceof RegExp) {
|
|
111
|
+
return left.source === right.source && left.flags === right.flags
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (left instanceof Set && right instanceof Set) {
|
|
115
|
+
return areSetsEqual(left, right)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (left instanceof Map && right instanceof Map) {
|
|
119
|
+
return areMapsEqual(left, right)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
123
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false
|
|
124
|
+
|
|
125
|
+
return left.every((value, index) => isDeepEqual(value, right[index]))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!isPlainObject(left) || !isPlainObject(right)) return false
|
|
129
|
+
|
|
130
|
+
const leftKeys = Object.keys(left)
|
|
131
|
+
const rightKeys = Object.keys(right)
|
|
132
|
+
|
|
133
|
+
if (leftKeys.length !== rightKeys.length) return false
|
|
134
|
+
|
|
135
|
+
for (const key of leftKeys) {
|
|
136
|
+
if (!hasOwn(right, key) || !isDeepEqual(left[key], right[key])) {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return true
|
|
142
|
+
}
|