@sanity/sdk 3.0.0-rc.1 → 3.0.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/README.md +12 -0
- package/dist/_exports/_internal.d.ts +71 -3
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +332 -24
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +65 -0
- package/dist/_exports/agent.d.ts.map +1 -0
- package/dist/_exports/agent.js +71 -0
- package/dist/_exports/agent.js.map +1 -0
- package/dist/_exports/comlink.d.ts +118 -0
- package/dist/_exports/comlink.d.ts.map +1 -0
- package/dist/_exports/comlink.js +69 -0
- package/dist/_exports/comlink.js.map +1 -0
- package/dist/authStore-C0o1NOxW.d.ts +3165 -0
- package/dist/authStore-C0o1NOxW.d.ts.map +1 -0
- package/dist/authStore-CtE9bLHW.js +754 -0
- package/dist/authStore-CtE9bLHW.js.map +1 -0
- package/dist/clientStore-BtE2-q2i.js +90 -0
- package/dist/clientStore-BtE2-q2i.js.map +1 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
- package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
- package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
- package/dist/getEnv-D5RGkQLu.js +8 -0
- package/dist/getEnv-D5RGkQLu.js.map +1 -0
- package/dist/getNodeState-CR3__B2D.js +67 -0
- package/dist/getNodeState-CR3__B2D.js.map +1 -0
- package/dist/index.d.ts +3 -2335
- package/dist/index.js +4048 -3878
- package/dist/index.js.map +1 -1
- package/dist/logger-DAtJb5RQ.js +173 -0
- package/dist/logger-DAtJb5RQ.js.map +1 -0
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/queryStore-C7yMHPUI.js +1346 -0
- package/dist/queryStore-C7yMHPUI.js.map +1 -0
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
- package/dist/telemetryManager-UIEiMtC0.js +127 -0
- package/dist/telemetryManager-UIEiMtC0.js.map +1 -0
- package/dist/version-D8RP_Q59.js +9 -0
- package/dist/version-D8RP_Q59.js.map +1 -0
- package/package.json +11 -17
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/agent.ts +19 -0
- package/src/_exports/comlink.ts +16 -0
- package/src/_exports/index.ts +45 -90
- package/src/applications/applications.test-d.ts +5 -0
- package/src/applications/applications.ts +5 -2
- package/src/auth/authMode.test.ts +0 -25
- package/src/auth/authMode.ts +3 -6
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/authStore.ts +0 -1
- package/src/comments/addonDatasetStore.test.ts +215 -0
- package/src/comments/addonDatasetStore.ts +249 -0
- package/src/comments/buildCommentThreads.test.ts +83 -0
- package/src/comments/buildCommentThreads.ts +54 -0
- package/src/comments/commentActions.test.ts +540 -0
- package/src/comments/commentActions.ts +555 -0
- package/src/comments/commentFieldPath.test.ts +40 -0
- package/src/comments/commentFieldPath.ts +44 -0
- package/src/comments/commentsConstants.ts +80 -0
- package/src/comments/commentsStore.test.ts +419 -0
- package/src/comments/commentsStore.ts +483 -0
- package/src/comments/normalizeComment.test.ts +116 -0
- package/src/comments/normalizeComment.ts +45 -0
- package/src/comments/observeComments.test.ts +193 -0
- package/src/comments/observeComments.ts +113 -0
- package/src/comments/reducers.test.ts +277 -0
- package/src/comments/reducers.ts +316 -0
- package/src/comments/types.ts +249 -0
- package/src/comments/weakenReferences.test.ts +47 -0
- package/src/comments/weakenReferences.ts +29 -0
- package/src/config/sanityConfig.ts +3 -75
- package/src/datasets/datasets.test.ts +44 -9
- package/src/datasets/datasets.ts +24 -23
- package/src/document/applyDocumentActions.test.ts +8 -12
- package/src/document/documentConstants.ts +17 -0
- package/src/document/documentStore.test.ts +173 -19
- package/src/document/documentStore.ts +36 -5
- package/src/document/patchOperations.test.ts +27 -0
- package/src/document/patchOperations.ts +10 -1
- package/src/document/permissions.test.ts +3 -1
- package/src/document/reducers.test.ts +357 -0
- package/src/document/reducers.ts +125 -27
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/favorites/favorites.test.ts +64 -24
- package/src/favorites/favorites.ts +73 -22
- package/src/installations/installations.test-d.ts +5 -0
- package/src/installations/installations.ts +4 -0
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/src/preview/types.ts +0 -25
- package/src/projection/projectionStore.test.ts +0 -8
- package/src/projection/types.ts +0 -8
- package/src/query/queryStore.ts +7 -6
- package/src/store/createActionBinder.test.ts +51 -29
- package/src/store/createActionBinder.ts +16 -62
- package/src/store/createSanityInstance.test.ts +0 -68
- package/src/store/createSanityInstance.ts +0 -63
- package/src/store/createStateSourceAction.ts +1 -1
- package/src/store/fetcherStore.test.ts +23 -1
- package/src/store/fetcherStore.ts +3 -3
- package/src/users/reducers.test.ts +0 -3
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -134
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
- package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
- package/dist/_chunks-es/telemetryManager.js +0 -93
- package/dist/_chunks-es/telemetryManager.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/src/applications/userApplications.test.ts +0 -126
- package/src/applications/userApplications.ts +0 -261
- package/src/preview/getPreviewState.test.ts +0 -137
- package/src/preview/getPreviewState.ts +0 -69
- package/src/preview/resolvePreview.test.ts +0 -64
- package/src/preview/resolvePreview.ts +0 -43
- package/src/utils/createFetcherStore.test.ts +0 -171
- package/src/utils/createFetcherStore.ts +0 -284
package/src/document/util.ts
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
|
-
import {DocumentId, getPublishedId, getVersionId} from '@sanity/id-utils'
|
|
1
|
+
import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
|
|
2
2
|
|
|
3
3
|
import {type DocumentHandle} from '../config/sanityConfig'
|
|
4
4
|
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The id of the specific document a user is editing under a given perspective:
|
|
8
|
+
* the draft, the published document, or a release version.
|
|
9
|
+
*
|
|
10
|
+
* Distinct from {@link getEffectiveDocumentId}, which resolves the canonical key a
|
|
11
|
+
* document's state is stored under and so collapses a draft onto its published id.
|
|
12
|
+
* This one keeps them apart, because some things compare the exact id. Presence is
|
|
13
|
+
* the motivating case: the Studio's field indicators match the id its form is on,
|
|
14
|
+
* exactly, so an app reporting the published id shows up in the Studio at document
|
|
15
|
+
* level while never lighting up a single field.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* Resolved from the handle alone, which is right whenever a draft or version
|
|
19
|
+
* exists, so for any document actually being edited. The Studio additionally falls
|
|
20
|
+
* back to whichever document exists, so for a published document nobody has edited
|
|
21
|
+
* yet its form sits on the published id where this returns the draft id. Matching
|
|
22
|
+
* that would mean reading document state, which presence deliberately does not
|
|
23
|
+
* depend on.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
export function getEditingDocumentId(
|
|
28
|
+
doc: Pick<DocumentHandle, 'documentId' | 'liveEdit' | 'perspective'>,
|
|
29
|
+
): string {
|
|
30
|
+
if (doc.liveEdit) {
|
|
31
|
+
return getPublishedId(DocumentId(doc.documentId))
|
|
32
|
+
}
|
|
33
|
+
if (isReleasePerspective(doc.perspective)) {
|
|
34
|
+
return getVersionId(DocumentId(doc.documentId), doc.perspective.releaseName)
|
|
35
|
+
}
|
|
36
|
+
if (doc.perspective === 'published') {
|
|
37
|
+
return getPublishedId(DocumentId(doc.documentId))
|
|
38
|
+
}
|
|
39
|
+
return getDraftId(DocumentId(doc.documentId))
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
export function getEffectiveDocumentId(doc: DocumentHandle): string {
|
|
7
43
|
if (doc.liveEdit) {
|
|
8
44
|
return doc.documentId
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {type Node} from '@sanity/comlink'
|
|
2
|
-
import {
|
|
2
|
+
import {of, Subject} from 'rxjs'
|
|
3
3
|
import {describe, expect, it, type Mock, vi} from 'vitest'
|
|
4
4
|
|
|
5
5
|
import {getNodeState, type NodeState} from '../comlink/node/getNodeState'
|
|
6
6
|
import {type FrameMessage, type WindowMessage} from '../comlink/types'
|
|
7
7
|
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
8
8
|
import {type StateSource} from '../store/createStateSourceAction'
|
|
9
|
-
import {
|
|
9
|
+
import {favorites, setFavorite} from './favorites'
|
|
10
10
|
|
|
11
11
|
vi.mock('../comlink/node/getNodeState', () => ({
|
|
12
12
|
getNodeState: vi.fn(),
|
|
@@ -62,8 +62,8 @@ describe('favoritesStore', () => {
|
|
|
62
62
|
it('creates different keys for different contexts with schema name', async () => {
|
|
63
63
|
setupMockStateSource()
|
|
64
64
|
// Make two fetches with different document IDs
|
|
65
|
-
await
|
|
66
|
-
await
|
|
65
|
+
await favorites.resolveState(instance!, mockContext)
|
|
66
|
+
await favorites.resolveState(instance!, {
|
|
67
67
|
...mockContext,
|
|
68
68
|
documentId: 'different',
|
|
69
69
|
})
|
|
@@ -79,8 +79,8 @@ describe('favoritesStore', () => {
|
|
|
79
79
|
it('creates different keys for contexts without schema name', async () => {
|
|
80
80
|
setupMockStateSource()
|
|
81
81
|
// Make two fetches with different document IDs
|
|
82
|
-
await
|
|
83
|
-
await
|
|
82
|
+
await favorites.resolveState(instance!, mockContextNoSchema)
|
|
83
|
+
await favorites.resolveState(instance!, {
|
|
84
84
|
...mockContextNoSchema,
|
|
85
85
|
documentId: 'different',
|
|
86
86
|
})
|
|
@@ -110,7 +110,7 @@ describe('favoritesStore', () => {
|
|
|
110
110
|
it('fetches favorite status and handles success', async () => {
|
|
111
111
|
const mockResponse = {isFavorited: true}
|
|
112
112
|
setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue(mockResponse)})
|
|
113
|
-
const result = await
|
|
113
|
+
const result = await favorites.resolveState(instance!, mockContext)
|
|
114
114
|
expect(result).toEqual(mockResponse)
|
|
115
115
|
expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/query', {
|
|
116
116
|
document: {
|
|
@@ -128,7 +128,7 @@ describe('favoritesStore', () => {
|
|
|
128
128
|
it('handles error and returns default response', async () => {
|
|
129
129
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
130
130
|
setupMockStateSource({fetchImpl: vi.fn().mockRejectedValue(new Error('Failed to fetch'))})
|
|
131
|
-
const result = await
|
|
131
|
+
const result = await favorites.resolveState(instance!, mockContext)
|
|
132
132
|
expect(result).toEqual({isFavorited: false})
|
|
133
133
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
134
134
|
'Favorites service connection error',
|
|
@@ -137,25 +137,19 @@ describe('favoritesStore', () => {
|
|
|
137
137
|
consoleErrorSpy.mockRestore()
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
it('
|
|
140
|
+
it('serves subsequent reads within staleTime from the cache', async () => {
|
|
141
141
|
const mockResponse = {isFavorited: true}
|
|
142
142
|
setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue(mockResponse)})
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
const sub1 = state.subscribe()
|
|
146
|
-
await firstValueFrom(state.observable)
|
|
143
|
+
const first = await favorites.resolveState(instance!, mockContext)
|
|
144
|
+
expect(first).toEqual(mockResponse)
|
|
147
145
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
148
|
-
// Second
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
await firstValueFrom(state2.observable)
|
|
146
|
+
// Second read is fresh (within staleTime) — no second fetch
|
|
147
|
+
const second = await favorites.resolveState(instance!, mockContext)
|
|
148
|
+
expect(second).toEqual(mockResponse)
|
|
152
149
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
153
|
-
// Cleanup
|
|
154
|
-
sub1()
|
|
155
|
-
sub2()
|
|
156
150
|
})
|
|
157
151
|
|
|
158
|
-
it('
|
|
152
|
+
it('dedupes against the in-flight fetch when called again while pending', async () => {
|
|
159
153
|
vi.useFakeTimers()
|
|
160
154
|
let resolveFetch: (value: {isFavorited: boolean}) => void
|
|
161
155
|
const fetchPromise = new Promise<{isFavorited: boolean}>((resolve) => {
|
|
@@ -175,13 +169,15 @@ describe('favoritesStore', () => {
|
|
|
175
169
|
} as unknown as StateSource<NodeState>
|
|
176
170
|
vi.mocked(getNodeState).mockReturnValue(mockStateSource)
|
|
177
171
|
// Call 1: Triggers the actual fetch
|
|
178
|
-
const promise1 =
|
|
172
|
+
const promise1 = favorites.resolveState(instance!, mockContext)
|
|
173
|
+
// Let the deferred fetch start and subscribe to the node state
|
|
174
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
179
175
|
// Simulate node becoming connected
|
|
180
176
|
subject.next({node: mockNode, status: 'connected'})
|
|
181
177
|
await vi.advanceTimersByTimeAsync(1)
|
|
182
178
|
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
183
|
-
// Call 2: Should reuse the pending fetch
|
|
184
|
-
const promise2 =
|
|
179
|
+
// Call 2: Should reuse the pending fetch
|
|
180
|
+
const promise2 = favorites.resolveState(instance!, mockContext)
|
|
185
181
|
await vi.advanceTimersByTimeAsync(1)
|
|
186
182
|
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
187
183
|
// Resolve the underlying fetch
|
|
@@ -195,4 +191,48 @@ describe('favoritesStore', () => {
|
|
|
195
191
|
vi.useRealTimers()
|
|
196
192
|
})
|
|
197
193
|
})
|
|
194
|
+
|
|
195
|
+
describe('setFavorite', () => {
|
|
196
|
+
beforeEach(() => {
|
|
197
|
+
vi.resetAllMocks()
|
|
198
|
+
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
199
|
+
setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue({success: true})})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
afterEach(() => {
|
|
203
|
+
instance?.dispose()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('sends the added event with schemaName and resolves to the new state', async () => {
|
|
207
|
+
const result = await setFavorite(instance!, {...mockContext, isFavorited: true})
|
|
208
|
+
expect(result.data).toEqual({isFavorited: true})
|
|
209
|
+
expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/mutate', {
|
|
210
|
+
eventType: 'added',
|
|
211
|
+
document: {
|
|
212
|
+
id: mockContext.documentId,
|
|
213
|
+
type: mockContext.documentType,
|
|
214
|
+
resource: {
|
|
215
|
+
id: mockContext.resourceId,
|
|
216
|
+
type: mockContext.resourceType,
|
|
217
|
+
schemaName: mockContext.schemaName,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('sends the removed event and omits schemaName when absent', async () => {
|
|
224
|
+
const result = await setFavorite(instance!, {...mockContextNoSchema, isFavorited: false})
|
|
225
|
+
expect(result.data).toEqual({isFavorited: false})
|
|
226
|
+
const payload = mockFetch.mock.calls[0][1]
|
|
227
|
+
expect(payload.eventType).toBe('removed')
|
|
228
|
+
expect(payload.document.resource).not.toHaveProperty('schemaName')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('rejects when the server reports failure', async () => {
|
|
232
|
+
setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue({success: false})})
|
|
233
|
+
await expect(setFavorite(instance!, {...mockContext, isFavorited: true})).rejects.toThrow(
|
|
234
|
+
'Failed to update favorite status',
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
198
238
|
})
|
|
@@ -5,12 +5,12 @@ import {
|
|
|
5
5
|
SDK_NODE_NAME,
|
|
6
6
|
type StudioResource,
|
|
7
7
|
} from '@sanity/message-protocol'
|
|
8
|
-
import {catchError,
|
|
8
|
+
import {catchError, first, from, map, type Observable, of, switchMap} from 'rxjs'
|
|
9
9
|
|
|
10
10
|
import {getNodeState} from '../comlink/node/getNodeState'
|
|
11
11
|
import {type DocumentHandle} from '../config/sanityConfig'
|
|
12
12
|
import {type SanityInstance} from '../store/createSanityInstance'
|
|
13
|
-
import {
|
|
13
|
+
import {defineFetcher, defineMutation} from '../store/fetcherStore'
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* @public
|
|
@@ -22,7 +22,7 @@ export interface FavoriteStatusResponse {
|
|
|
22
22
|
/**
|
|
23
23
|
* @public
|
|
24
24
|
*/
|
|
25
|
-
interface FavoriteDocumentContext extends DocumentHandle {
|
|
25
|
+
export interface FavoriteDocumentContext extends DocumentHandle {
|
|
26
26
|
resourceId: string
|
|
27
27
|
resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
|
|
28
28
|
schemaName?: string
|
|
@@ -35,12 +35,19 @@ function createFavoriteKey(context: FavoriteDocumentContext): string {
|
|
|
35
35
|
}`
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Fetcher for a document's favorite status, read from the dashboard over
|
|
40
|
+
* comlink, on the shared fetcher cache.
|
|
41
|
+
*
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export const favorites = defineFetcher<[context: FavoriteDocumentContext], FavoriteStatusResponse>({
|
|
45
|
+
name: 'favorites',
|
|
46
|
+
getKey: (_instance: SanityInstance, context: FavoriteDocumentContext) =>
|
|
47
|
+
createFavoriteKey(context),
|
|
48
|
+
// Tag each entry with its key so `setFavorite` can invalidate exactly it.
|
|
49
|
+
tags: (_data, context) => [{type: 'favorite', id: createFavoriteKey(context)}],
|
|
50
|
+
fetch: (instance) => {
|
|
44
51
|
return (context: FavoriteDocumentContext): Observable<FavoriteStatusResponse> => {
|
|
45
52
|
const nodeStateSource = getNodeState(instance, {
|
|
46
53
|
name: SDK_NODE_NAME,
|
|
@@ -59,8 +66,8 @@ const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusRe
|
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
return nodeStateSource.observable.pipe(
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
// Wait until connected, then complete after the single fetch settles.
|
|
70
|
+
first((nodeState) => !!nodeState),
|
|
64
71
|
switchMap((nodeState) => {
|
|
65
72
|
const node = nodeState!.node
|
|
66
73
|
return from(
|
|
@@ -84,19 +91,63 @@ const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusRe
|
|
|
84
91
|
})
|
|
85
92
|
|
|
86
93
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* @param context - The document context including ID, type, and resource information.
|
|
90
|
-
* @returns A StateSource emitting `{ isFavorited: boolean }`.
|
|
94
|
+
* Input for {@link setFavorite}: the document context plus the desired state.
|
|
95
|
+
*
|
|
91
96
|
* @public
|
|
92
97
|
*/
|
|
93
|
-
export
|
|
98
|
+
export type SetFavoriteInput = FavoriteDocumentContext & {
|
|
99
|
+
/** The favorite state to move to: `true` to favorite, `false` to unfavorite. */
|
|
100
|
+
isFavorited: boolean
|
|
101
|
+
}
|
|
94
102
|
|
|
95
103
|
/**
|
|
96
|
-
*
|
|
97
|
-
* @
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @
|
|
104
|
+
* Sets a document's favorite state over comlink, then invalidates the cached
|
|
105
|
+
* {@link favorites} status for that document so active readers reconverge on
|
|
106
|
+
* server truth. The write-side counterpart to {@link favorites}.
|
|
107
|
+
*
|
|
108
|
+
* @internal
|
|
101
109
|
*/
|
|
102
|
-
export const
|
|
110
|
+
export const setFavorite = defineMutation<SetFavoriteInput, FavoriteStatusResponse>({
|
|
111
|
+
name: 'setFavorite',
|
|
112
|
+
mutationFn: (instance) => {
|
|
113
|
+
return ({isFavorited, ...context}: SetFavoriteInput): Observable<FavoriteStatusResponse> => {
|
|
114
|
+
const nodeStateSource = getNodeState(instance, {
|
|
115
|
+
name: SDK_NODE_NAME,
|
|
116
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
117
|
+
})
|
|
118
|
+
const payload = {
|
|
119
|
+
eventType: isFavorited ? 'added' : 'removed',
|
|
120
|
+
document: {
|
|
121
|
+
id: context.documentId,
|
|
122
|
+
type: context.documentType,
|
|
123
|
+
resource: {
|
|
124
|
+
id: context.resourceId,
|
|
125
|
+
type: context.resourceType,
|
|
126
|
+
...(context.schemaName ? {schemaName: context.schemaName} : {}),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return nodeStateSource.observable.pipe(
|
|
132
|
+
// Wait until connected, then complete after the single mutation settles.
|
|
133
|
+
first((nodeState) => !!nodeState),
|
|
134
|
+
switchMap((nodeState) => {
|
|
135
|
+
const node = nodeState!.node
|
|
136
|
+
return from(
|
|
137
|
+
node.fetch(
|
|
138
|
+
// @ts-expect-error -- getOrCreateNode should be refactored to take type arguments
|
|
139
|
+
'dashboard/v1/events/favorite/mutate',
|
|
140
|
+
payload,
|
|
141
|
+
) as Promise<{success: boolean}>,
|
|
142
|
+
).pipe(
|
|
143
|
+
map((response) => {
|
|
144
|
+
if (!response.success) throw new Error('Failed to update favorite status')
|
|
145
|
+
return {isFavorited}
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
invalidates: (_result, input) => [{type: 'favorite', id: createFavoriteKey(input)}],
|
|
153
|
+
})
|
|
@@ -29,6 +29,11 @@ test('Installation — no includes: only the base shape', () => {
|
|
|
29
29
|
expectTypeOf<Extract<keyof Installation, IncludeKeys>>().toEqualTypeOf<never>()
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
+
test('InstallationBase — the application sub-object carries name and reference', () => {
|
|
33
|
+
expectTypeOf<InstallationBase['application']['name']>().toEqualTypeOf<string>()
|
|
34
|
+
expectTypeOf<InstallationBase['application']['reference']>().toEqualTypeOf<string>()
|
|
35
|
+
})
|
|
36
|
+
|
|
32
37
|
test('Installation — each token adds its top-level field, required, others absent', () => {
|
|
33
38
|
expectTypeOf<Installation<'access'>['access']>().toEqualTypeOf<InstallationAccess[]>()
|
|
34
39
|
expectTypeOf<
|
|
@@ -79,6 +79,10 @@ export interface InstallationBase {
|
|
|
79
79
|
updatedAt: string
|
|
80
80
|
application: {
|
|
81
81
|
title: string
|
|
82
|
+
/** Stable, immutable identity of the installed singleton, distinct from `slug`. */
|
|
83
|
+
name: string
|
|
84
|
+
/** Qualified, globally-unique handle of the installed singleton (e.g. `sanity/<name>`). */
|
|
85
|
+
reference: string
|
|
82
86
|
slug: string | null
|
|
83
87
|
}
|
|
84
88
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {fromUrl} from '@sanity/bifur-client'
|
|
2
2
|
import {type SanityClient} from '@sanity/client'
|
|
3
|
-
import {of, Subject} from 'rxjs'
|
|
4
|
-
import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
3
|
+
import {type Observable, of, Subject} from 'rxjs'
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
5
5
|
|
|
6
6
|
import {createBifurTransport} from './bifurTransport'
|
|
7
7
|
import {type PresenceLocation, type TransportEvent} from './types'
|
|
@@ -39,16 +39,20 @@ describe('createBifurTransport', () => {
|
|
|
39
39
|
let mockBifurClient: {
|
|
40
40
|
listen: Mock
|
|
41
41
|
request: Mock
|
|
42
|
+
heartbeats: Observable<Date>
|
|
42
43
|
}
|
|
43
44
|
let mockSanityClient: SanityClient
|
|
44
45
|
let token$: Subject<string | null>
|
|
46
|
+
let heartbeats$: Subject<Date>
|
|
45
47
|
|
|
46
48
|
beforeEach(() => {
|
|
47
49
|
vi.useFakeTimers()
|
|
48
50
|
vi.clearAllMocks()
|
|
51
|
+
heartbeats$ = new Subject<Date>()
|
|
49
52
|
mockBifurClient = {
|
|
50
53
|
listen: vi.fn(() => new Subject<never>()),
|
|
51
54
|
request: vi.fn(() => of(undefined)),
|
|
55
|
+
heartbeats: heartbeats$.asObservable(),
|
|
52
56
|
}
|
|
53
57
|
fromUrlMock.mockReturnValue(mockBifurClient)
|
|
54
58
|
|
|
@@ -130,6 +134,8 @@ describe('createBifurTransport', () => {
|
|
|
130
134
|
|
|
131
135
|
const receivedEvents: TransportEvent[] = []
|
|
132
136
|
incomingEvents$.subscribe((event) => receivedEvents.push(event))
|
|
137
|
+
// The listener is established per live connection, so bring one up first.
|
|
138
|
+
heartbeats$.next(new Date())
|
|
133
139
|
|
|
134
140
|
incomingBifurEvents$.next({
|
|
135
141
|
type: 'rollCall',
|
|
@@ -161,6 +167,8 @@ describe('createBifurTransport', () => {
|
|
|
161
167
|
|
|
162
168
|
const receivedEvents: TransportEvent[] = []
|
|
163
169
|
incomingEvents$.subscribe((event) => receivedEvents.push(event))
|
|
170
|
+
// The listener is established per live connection, so bring one up first.
|
|
171
|
+
heartbeats$.next(new Date())
|
|
164
172
|
|
|
165
173
|
const locations: PresenceLocation[] = [
|
|
166
174
|
{type: 'document', documentId: 'doc1', path: ['a'], lastActiveAt: new Date().toISOString()},
|
|
@@ -200,6 +208,8 @@ describe('createBifurTransport', () => {
|
|
|
200
208
|
|
|
201
209
|
const receivedEvents: TransportEvent[] = []
|
|
202
210
|
incomingEvents$.subscribe((event) => receivedEvents.push(event))
|
|
211
|
+
// The listener is established per live connection, so bring one up first.
|
|
212
|
+
heartbeats$.next(new Date())
|
|
203
213
|
|
|
204
214
|
incomingBifurEvents$.next({
|
|
205
215
|
type: 'disconnect',
|
|
@@ -233,6 +243,7 @@ describe('createBifurTransport', () => {
|
|
|
233
243
|
incomingEvents$.subscribe({
|
|
234
244
|
error: (err) => errors.push(err),
|
|
235
245
|
})
|
|
246
|
+
heartbeats$.next(new Date())
|
|
236
247
|
|
|
237
248
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
238
249
|
incomingBifurEvents$.next({type: 'unknown'} as any)
|
|
@@ -242,7 +253,148 @@ describe('createBifurTransport', () => {
|
|
|
242
253
|
expect(errors[0].message).toContain('Got unknown presence event')
|
|
243
254
|
})
|
|
244
255
|
|
|
245
|
-
describe('
|
|
256
|
+
describe('connections', () => {
|
|
257
|
+
it('emits an incrementing generation each time the socket becomes live', () => {
|
|
258
|
+
const [, , connections$] = createBifurTransport({
|
|
259
|
+
client: mockSanityClient,
|
|
260
|
+
token$,
|
|
261
|
+
sessionId: 'my-session',
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const generations: number[] = []
|
|
265
|
+
connections$.subscribe((generation) => generations.push(generation))
|
|
266
|
+
|
|
267
|
+
// Several heartbeats on one live socket are a single generation.
|
|
268
|
+
heartbeats$.next(new Date())
|
|
269
|
+
heartbeats$.next(new Date())
|
|
270
|
+
expect(generations).toEqual([1])
|
|
271
|
+
|
|
272
|
+
// bifur-client errors the stream when the socket closes. The transport is
|
|
273
|
+
// expected to reconnect rather than give up for the life of the page.
|
|
274
|
+
const reconnected$ = new Subject<Date>()
|
|
275
|
+
mockBifurClient.heartbeats = reconnected$.asObservable()
|
|
276
|
+
heartbeats$.error(new Error('WebSocket connection error'))
|
|
277
|
+
|
|
278
|
+
// First retry is scheduled 200ms out (2 ** 1 * 100).
|
|
279
|
+
vi.advanceTimersByTime(200)
|
|
280
|
+
reconnected$.next(new Date())
|
|
281
|
+
|
|
282
|
+
expect(generations).toEqual([1, 2])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('does not resubscribe before the backoff has elapsed', () => {
|
|
286
|
+
const [, , connections$] = createBifurTransport({
|
|
287
|
+
client: mockSanityClient,
|
|
288
|
+
token$,
|
|
289
|
+
sessionId: 'my-session',
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const generations: number[] = []
|
|
293
|
+
connections$.subscribe((generation) => generations.push(generation))
|
|
294
|
+
heartbeats$.next(new Date())
|
|
295
|
+
|
|
296
|
+
const reconnected$ = new Subject<Date>()
|
|
297
|
+
mockBifurClient.heartbeats = reconnected$.asObservable()
|
|
298
|
+
heartbeats$.error(new Error('WebSocket connection error'))
|
|
299
|
+
|
|
300
|
+
vi.advanceTimersByTime(199)
|
|
301
|
+
reconnected$.next(new Date())
|
|
302
|
+
expect(generations).toEqual([1])
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
describe('incoming events across a reconnect', () => {
|
|
307
|
+
it('keeps delivering presence after the socket drops', () => {
|
|
308
|
+
// bifur errors every stream on the same socket close, the inbound listener
|
|
309
|
+
// included. If only the heartbeat stream recovers, this client re-announces
|
|
310
|
+
// itself to peers while permanently ceasing to see any of them.
|
|
311
|
+
const firstListen$ = new Subject<IncomingBifurEvent>()
|
|
312
|
+
const secondListen$ = new Subject<IncomingBifurEvent>()
|
|
313
|
+
mockBifurClient.listen.mockReturnValueOnce(firstListen$).mockReturnValueOnce(secondListen$)
|
|
314
|
+
|
|
315
|
+
const [incomingEvents$, , connections$] = createBifurTransport({
|
|
316
|
+
client: mockSanityClient,
|
|
317
|
+
token$,
|
|
318
|
+
sessionId: 'my-session',
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const received: TransportEvent[] = []
|
|
322
|
+
incomingEvents$.subscribe({
|
|
323
|
+
next: (event) => received.push(event),
|
|
324
|
+
error: () => received.push({type: 'rollCall', userId: 'STREAM_DIED', sessionId: ''}),
|
|
325
|
+
})
|
|
326
|
+
connections$.subscribe()
|
|
327
|
+
|
|
328
|
+
heartbeats$.next(new Date())
|
|
329
|
+
firstListen$.next({type: 'rollCall', i: 'user-1', session: 'before'})
|
|
330
|
+
expect(received.map((e) => e.sessionId)).toEqual(['before'])
|
|
331
|
+
|
|
332
|
+
// Socket closes: both streams error.
|
|
333
|
+
const reconnectedHeartbeats$ = new Subject<Date>()
|
|
334
|
+
mockBifurClient.heartbeats = reconnectedHeartbeats$.asObservable()
|
|
335
|
+
firstListen$.error(new Error('WebSocket connection error'))
|
|
336
|
+
heartbeats$.error(new Error('WebSocket connection error'))
|
|
337
|
+
|
|
338
|
+
vi.advanceTimersByTime(200)
|
|
339
|
+
reconnectedHeartbeats$.next(new Date())
|
|
340
|
+
|
|
341
|
+
// Peers answer the roll call we send on reconnect. We have to be listening.
|
|
342
|
+
secondListen$.next({type: 'rollCall', i: 'user-1', session: 'after'})
|
|
343
|
+
|
|
344
|
+
expect(received.map((e) => e.sessionId)).toEqual(['before', 'after'])
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('unload', () => {
|
|
349
|
+
afterEach(() => {
|
|
350
|
+
vi.unstubAllGlobals()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('emits on beforeunload and pagehide, and removes its listeners', () => {
|
|
354
|
+
// These tests run in Node, so stand in a bare EventTarget for `window`.
|
|
355
|
+
const fakeWindow = new EventTarget()
|
|
356
|
+
vi.stubGlobal('window', fakeWindow)
|
|
357
|
+
|
|
358
|
+
const [, , , unload$] = createBifurTransport({
|
|
359
|
+
client: mockSanityClient,
|
|
360
|
+
token$,
|
|
361
|
+
sessionId: 'my-session',
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
let emissions = 0
|
|
365
|
+
const subscription = unload$.subscribe(() => {
|
|
366
|
+
emissions += 1
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
fakeWindow.dispatchEvent(new Event('beforeunload'))
|
|
370
|
+
expect(emissions).toBe(1)
|
|
371
|
+
|
|
372
|
+
// `pagehide` matters on its own: iOS Safari and pages entering the
|
|
373
|
+
// back/forward cache never fire `beforeunload`.
|
|
374
|
+
fakeWindow.dispatchEvent(new Event('pagehide'))
|
|
375
|
+
expect(emissions).toBe(2)
|
|
376
|
+
|
|
377
|
+
// The listeners belong to the subscription, so unsubscribing removes them
|
|
378
|
+
// instead of leaking one per transport for the life of the page.
|
|
379
|
+
subscription.unsubscribe()
|
|
380
|
+
fakeWindow.dispatchEvent(new Event('beforeunload'))
|
|
381
|
+
expect(emissions).toBe(2)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('is inert when there is no window', () => {
|
|
385
|
+
const [, , , unload$] = createBifurTransport({
|
|
386
|
+
client: mockSanityClient,
|
|
387
|
+
token$,
|
|
388
|
+
sessionId: 'my-session',
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
let completed = false
|
|
392
|
+
unload$.subscribe({complete: () => (completed = true)})
|
|
393
|
+
expect(completed).toBe(true)
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
describe('dispatch', () => {
|
|
246
398
|
it('sends a "rollCall" message', () => {
|
|
247
399
|
const [, dispatchMessage] = createBifurTransport({
|
|
248
400
|
client: mockSanityClient,
|