@sanity/sdk 2.15.0 → 2.17.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/createGroqSearchFilter.d.ts +5 -4
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/index.d.ts +69 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -32
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/_exports/index.ts +2 -0
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/handleAuthCallback.test.ts +1 -1
- package/src/auth/refreshStampedToken.test.ts +199 -477
- package/src/auth/refreshStampedToken.ts +67 -195
- package/src/client/liveEvents.test.ts +151 -0
- package/src/client/liveEvents.ts +107 -0
- package/src/document/actions.ts +36 -0
- package/src/document/documentStore.test.ts +132 -1
- package/src/document/documentStore.ts +8 -0
- package/src/document/events.ts +32 -0
- package/src/document/listen.ts +8 -0
- package/src/document/processActions/edit.ts +19 -14
- package/src/document/processActions/shared.ts +55 -8
- package/src/document/processActions.test.ts +145 -0
- package/src/document/reducers.test.ts +396 -0
- package/src/document/reducers.ts +62 -1
- package/src/document/sharedListener.test.ts +1 -0
- package/src/document/sharedListener.ts +2 -0
- package/src/query/queryStore.test.ts +232 -38
- package/src/query/queryStore.ts +47 -47
- package/src/query/reducers.test.ts +0 -42
- package/src/query/reducers.ts +0 -16
- package/src/releases/getPerspectiveState.test.ts +3 -8
- package/src/releases/getPerspectiveState.ts +6 -19
- package/src/releases/observeReleases.test.ts +127 -0
- package/src/releases/observeReleases.ts +102 -0
- package/src/releases/releasesStore.test.ts +51 -53
- package/src/releases/releasesStore.ts +32 -32
- package/src/releases/utils/sortReleases.test.ts +36 -0
- package/src/releases/utils/sortReleases.ts +14 -9
|
@@ -2,8 +2,6 @@ import {
|
|
|
2
2
|
distinctUntilChanged,
|
|
3
3
|
exhaustMap,
|
|
4
4
|
filter,
|
|
5
|
-
firstValueFrom,
|
|
6
|
-
from,
|
|
7
5
|
map,
|
|
8
6
|
Observable,
|
|
9
7
|
type Subscription,
|
|
@@ -19,37 +17,6 @@ import {AuthStateType} from './authStateType'
|
|
|
19
17
|
import {type AuthState, type AuthStoreState} from './authStore'
|
|
20
18
|
|
|
21
19
|
const REFRESH_INTERVAL = 12 * 60 * 60 * 1000 // 12 hours in milliseconds
|
|
22
|
-
const LOCK_NAME = 'sanity-token-refresh-lock'
|
|
23
|
-
|
|
24
|
-
/** @internal */
|
|
25
|
-
export function getLastRefreshTime(storageArea: Storage | undefined, storageKey: string): number {
|
|
26
|
-
try {
|
|
27
|
-
const data = storageArea?.getItem(`${storageKey}_last_refresh`)
|
|
28
|
-
const parsed = data ? parseInt(data, 10) : 0
|
|
29
|
-
return isNaN(parsed) ? 0 : parsed
|
|
30
|
-
} catch {
|
|
31
|
-
return 0
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** @internal */
|
|
36
|
-
export function setLastRefreshTime(storageArea: Storage | undefined, storageKey: string): void {
|
|
37
|
-
try {
|
|
38
|
-
storageArea?.setItem(`${storageKey}_last_refresh`, Date.now().toString())
|
|
39
|
-
} catch {
|
|
40
|
-
// Ignore storage errors
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** @internal */
|
|
45
|
-
export function getNextRefreshDelay(storageArea: Storage | undefined, storageKey: string): number {
|
|
46
|
-
const lastRefresh = getLastRefreshTime(storageArea, storageKey)
|
|
47
|
-
if (!lastRefresh) return 0
|
|
48
|
-
|
|
49
|
-
const now = Date.now()
|
|
50
|
-
const nextRefreshTime = lastRefresh + REFRESH_INTERVAL
|
|
51
|
-
return Math.max(0, nextRefreshTime - now)
|
|
52
|
-
}
|
|
53
20
|
|
|
54
21
|
function createTokenRefreshStream(
|
|
55
22
|
token: string,
|
|
@@ -82,57 +49,6 @@ function createTokenRefreshStream(
|
|
|
82
49
|
})
|
|
83
50
|
}
|
|
84
51
|
|
|
85
|
-
async function acquireTokenRefreshLock(
|
|
86
|
-
refreshFn: () => Promise<void>,
|
|
87
|
-
storageArea: Storage | undefined,
|
|
88
|
-
storageKey: string,
|
|
89
|
-
): Promise<boolean> {
|
|
90
|
-
if (!navigator.locks) {
|
|
91
|
-
// If Web Locks API is not supported, perform an immediate, uncoordinated refresh.
|
|
92
|
-
// eslint-disable-next-line no-console
|
|
93
|
-
console.warn('Web Locks API not supported. Proceeding with uncoordinated refresh.')
|
|
94
|
-
await refreshFn()
|
|
95
|
-
setLastRefreshTime(storageArea, storageKey)
|
|
96
|
-
return true // Indicate success to allow stream processing, though it won't loop.
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
// Attempt to acquire an exclusive lock for token refresh coordination.
|
|
101
|
-
// The callback handles the continuous refresh cycle while the lock is held.
|
|
102
|
-
const result = await navigator.locks.request(LOCK_NAME, {mode: 'exclusive'}, async (lock) => {
|
|
103
|
-
if (!lock) return false // Lock not granted
|
|
104
|
-
|
|
105
|
-
// Problematic infinite loop - needs redesign for graceful termination.
|
|
106
|
-
// This loop continuously refreshes the token at REFRESH_INTERVAL.
|
|
107
|
-
while (true) {
|
|
108
|
-
const delay = getNextRefreshDelay(storageArea, storageKey)
|
|
109
|
-
if (delay > 0) {
|
|
110
|
-
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
await refreshFn()
|
|
114
|
-
setLastRefreshTime(storageArea, storageKey)
|
|
115
|
-
} catch (error) {
|
|
116
|
-
// eslint-disable-next-line no-console
|
|
117
|
-
console.error('Token refresh failed within lock:', error)
|
|
118
|
-
// Decide how to handle errors - break, retry, etc.? Currently logs and continues.
|
|
119
|
-
}
|
|
120
|
-
// Wait for the next interval
|
|
121
|
-
await new Promise((resolve) => setTimeout(resolve, REFRESH_INTERVAL))
|
|
122
|
-
}
|
|
123
|
-
// Unreachable due to while(true)
|
|
124
|
-
})
|
|
125
|
-
// The promise from navigator.locks.request resolves with the callback's return value,
|
|
126
|
-
// but only if the callback finishes. The infinite loop prevents this.
|
|
127
|
-
return result === true
|
|
128
|
-
} catch (error) {
|
|
129
|
-
// Handle potential errors during the initial lock request itself.
|
|
130
|
-
// eslint-disable-next-line no-console
|
|
131
|
-
console.error('Failed to request token refresh lock:', error)
|
|
132
|
-
return false // Indicate lock acquisition failure.
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
52
|
function shouldRefreshToken(lastRefresh: number | undefined): boolean {
|
|
137
53
|
if (!lastRefresh) return true
|
|
138
54
|
const timeSinceLastRefresh = Date.now() - lastRefresh
|
|
@@ -140,6 +56,14 @@ function shouldRefreshToken(lastRefresh: number | undefined): boolean {
|
|
|
140
56
|
}
|
|
141
57
|
|
|
142
58
|
/**
|
|
59
|
+
* Periodically refreshes stamped auth tokens (those containing `-st`).
|
|
60
|
+
*
|
|
61
|
+
* Two triggers cause a refresh while the user stays logged in:
|
|
62
|
+
* - a 12-hour interval timer (only fires while the tab is visible)
|
|
63
|
+
* - the tab becoming visible again after `lastTokenRefresh` has gone stale
|
|
64
|
+
*
|
|
65
|
+
* Non-stamped tokens (e.g. personal access tokens) are never refreshed.
|
|
66
|
+
*
|
|
143
67
|
* @internal
|
|
144
68
|
*/
|
|
145
69
|
export const refreshStampedToken = ({
|
|
@@ -151,96 +75,27 @@ export const refreshStampedToken = ({
|
|
|
151
75
|
const {clientFactory, apiHost, storageArea, storageKey} = state.get().options
|
|
152
76
|
|
|
153
77
|
const refreshToken$ = state.observable.pipe(
|
|
154
|
-
map((storeState) =>
|
|
155
|
-
authState: storeState.authState,
|
|
156
|
-
dashboardContext: storeState.dashboardContext,
|
|
157
|
-
})),
|
|
78
|
+
map((storeState) => storeState.authState),
|
|
158
79
|
filter(
|
|
159
|
-
(
|
|
160
|
-
|
|
161
|
-
): storeState is {
|
|
162
|
-
authState: Extract<AuthState, {type: AuthStateType.LOGGED_IN}>
|
|
163
|
-
dashboardContext: AuthStoreState['dashboardContext']
|
|
164
|
-
} => storeState.authState.type === AuthStateType.LOGGED_IN,
|
|
80
|
+
(authState): authState is Extract<AuthState, {type: AuthStateType.LOGGED_IN}> =>
|
|
81
|
+
authState.type === AuthStateType.LOGGED_IN,
|
|
165
82
|
),
|
|
166
|
-
distinctUntilChanged(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
const currentToken = currentState.authState.token
|
|
184
|
-
|
|
185
|
-
logger.debug('Refreshing stamped token')
|
|
186
|
-
const response = await firstValueFrom(
|
|
187
|
-
createTokenRefreshStream(currentToken, clientFactory, apiHost),
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
logger.info('Token refreshed successfully')
|
|
191
|
-
state.set('setRefreshStampedToken', (prev) => ({
|
|
192
|
-
authState:
|
|
193
|
-
prev.authState.type === AuthStateType.LOGGED_IN
|
|
194
|
-
? {...prev.authState, token: response.token}
|
|
195
|
-
: prev.authState,
|
|
196
|
-
}))
|
|
197
|
-
storageArea?.setItem(storageKey, JSON.stringify({token: response.token}))
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (storeState.dashboardContext) {
|
|
201
|
-
return new Observable<{token: string}>((subscriber) => {
|
|
202
|
-
const visibilityHandler = () => {
|
|
203
|
-
const currentState = state.get()
|
|
204
|
-
if (
|
|
205
|
-
document.visibilityState === 'visible' &&
|
|
206
|
-
currentState.authState.type === AuthStateType.LOGGED_IN &&
|
|
207
|
-
shouldRefreshToken(currentState.authState.lastTokenRefresh)
|
|
208
|
-
) {
|
|
209
|
-
createTokenRefreshStream(
|
|
210
|
-
currentState.authState.token,
|
|
211
|
-
clientFactory,
|
|
212
|
-
apiHost,
|
|
213
|
-
).subscribe({
|
|
214
|
-
next: (response) => {
|
|
215
|
-
state.set('setRefreshStampedToken', (prev) => ({
|
|
216
|
-
authState:
|
|
217
|
-
prev.authState.type === AuthStateType.LOGGED_IN
|
|
218
|
-
? {
|
|
219
|
-
...prev.authState,
|
|
220
|
-
token: response.token,
|
|
221
|
-
lastTokenRefresh: Date.now(),
|
|
222
|
-
}
|
|
223
|
-
: prev.authState,
|
|
224
|
-
}))
|
|
225
|
-
subscriber.next(response)
|
|
226
|
-
},
|
|
227
|
-
error: (error) => subscriber.error(error),
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const timerSubscription = timer(REFRESH_INTERVAL, REFRESH_INTERVAL)
|
|
233
|
-
.pipe(
|
|
234
|
-
filter(() => document.visibilityState === 'visible'),
|
|
235
|
-
switchMap(() => {
|
|
236
|
-
const currentState = state.get().authState
|
|
237
|
-
if (currentState.type !== AuthStateType.LOGGED_IN) {
|
|
238
|
-
throw new Error('User logged out before refresh could complete')
|
|
239
|
-
}
|
|
240
|
-
return createTokenRefreshStream(currentState.token, clientFactory, apiHost)
|
|
241
|
-
}),
|
|
242
|
-
)
|
|
243
|
-
.subscribe({
|
|
83
|
+
distinctUntilChanged((prev, curr) => prev.token === curr.token),
|
|
84
|
+
filter((authState) => authState.token.includes('-st')), // Ensure we only try to refresh stamped tokens
|
|
85
|
+
exhaustMap(() =>
|
|
86
|
+
new Observable<{token: string}>((subscriber) => {
|
|
87
|
+
const visibilityHandler = () => {
|
|
88
|
+
const currentState = state.get()
|
|
89
|
+
if (
|
|
90
|
+
document.visibilityState === 'visible' &&
|
|
91
|
+
currentState.authState.type === AuthStateType.LOGGED_IN &&
|
|
92
|
+
shouldRefreshToken(currentState.authState.lastTokenRefresh)
|
|
93
|
+
) {
|
|
94
|
+
createTokenRefreshStream(
|
|
95
|
+
currentState.authState.token,
|
|
96
|
+
clientFactory,
|
|
97
|
+
apiHost,
|
|
98
|
+
).subscribe({
|
|
244
99
|
next: (response) => {
|
|
245
100
|
state.set('setRefreshStampedToken', (prev) => ({
|
|
246
101
|
authState:
|
|
@@ -256,31 +111,48 @@ export const refreshStampedToken = ({
|
|
|
256
111
|
},
|
|
257
112
|
error: (error) => subscriber.error(error),
|
|
258
113
|
})
|
|
259
|
-
|
|
260
|
-
document.addEventListener('visibilitychange', visibilityHandler)
|
|
261
|
-
|
|
262
|
-
return () => {
|
|
263
|
-
document.removeEventListener('visibilitychange', visibilityHandler)
|
|
264
|
-
timerSubscription.unsubscribe()
|
|
265
114
|
}
|
|
266
|
-
}
|
|
267
|
-
takeWhile(() => state.get().authState.type === AuthStateType.LOGGED_IN),
|
|
268
|
-
map((response: {token: string}) => ({token: response.token})),
|
|
269
|
-
)
|
|
270
|
-
}
|
|
115
|
+
}
|
|
271
116
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
117
|
+
const timerSubscription = timer(REFRESH_INTERVAL, REFRESH_INTERVAL)
|
|
118
|
+
.pipe(
|
|
119
|
+
filter(() => document.visibilityState === 'visible'),
|
|
120
|
+
switchMap(() => {
|
|
121
|
+
const currentState = state.get().authState
|
|
122
|
+
if (currentState.type !== AuthStateType.LOGGED_IN) {
|
|
123
|
+
throw new Error('User logged out before refresh could complete')
|
|
124
|
+
}
|
|
125
|
+
return createTokenRefreshStream(currentState.token, clientFactory, apiHost)
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
.subscribe({
|
|
129
|
+
next: (response) => {
|
|
130
|
+
state.set('setRefreshStampedToken', (prev) => ({
|
|
131
|
+
authState:
|
|
132
|
+
prev.authState.type === AuthStateType.LOGGED_IN
|
|
133
|
+
? {
|
|
134
|
+
...prev.authState,
|
|
135
|
+
token: response.token,
|
|
136
|
+
lastTokenRefresh: Date.now(),
|
|
137
|
+
}
|
|
138
|
+
: prev.authState,
|
|
139
|
+
}))
|
|
140
|
+
subscriber.next(response)
|
|
141
|
+
},
|
|
142
|
+
error: (error) => subscriber.error(error),
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
document.addEventListener('visibilitychange', visibilityHandler)
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
document.removeEventListener('visibilitychange', visibilityHandler)
|
|
149
|
+
timerSubscription.unsubscribe()
|
|
150
|
+
}
|
|
151
|
+
}).pipe(
|
|
152
|
+
takeWhile(() => state.get().authState.type === AuthStateType.LOGGED_IN),
|
|
153
|
+
map((response: {token: string}) => ({token: response.token})),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
284
156
|
)
|
|
285
157
|
|
|
286
158
|
return refreshToken$.subscribe({
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionFailedError,
|
|
3
|
+
CorsOriginError,
|
|
4
|
+
DisconnectError,
|
|
5
|
+
type LiveEvent,
|
|
6
|
+
type LiveEventMessage,
|
|
7
|
+
type SanityClient,
|
|
8
|
+
} from '@sanity/client'
|
|
9
|
+
import {of, Subject} from 'rxjs'
|
|
10
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
13
|
+
import {type StateSource} from '../store/createStateSourceAction'
|
|
14
|
+
import {getClientState} from './clientStore'
|
|
15
|
+
import {LIVE_EVENTS_RETRY_DELAY, observeLiveEvents} from './liveEvents'
|
|
16
|
+
|
|
17
|
+
vi.mock('./clientStore', () => ({getClientState: vi.fn()}))
|
|
18
|
+
|
|
19
|
+
describe('observeLiveEvents', () => {
|
|
20
|
+
let instance: SanityInstance
|
|
21
|
+
let liveEventSubjects: Subject<LiveEvent>[]
|
|
22
|
+
let events: ReturnType<typeof vi.fn>
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks()
|
|
26
|
+
instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
27
|
+
|
|
28
|
+
liveEventSubjects = []
|
|
29
|
+
events = vi.fn().mockImplementation(() => {
|
|
30
|
+
const subject = new Subject<LiveEvent>()
|
|
31
|
+
liveEventSubjects.push(subject)
|
|
32
|
+
return subject
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
vi.mocked(getClientState).mockReturnValue({
|
|
36
|
+
observable: of({
|
|
37
|
+
config: vi.fn().mockReturnValue({token: 'token'}),
|
|
38
|
+
live: {events},
|
|
39
|
+
} as unknown as SanityClient),
|
|
40
|
+
} as StateSource<SanityClient>)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
instance.dispose()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('emits only message events', () => {
|
|
48
|
+
const messages: LiveEventMessage[] = []
|
|
49
|
+
const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe((message) =>
|
|
50
|
+
messages.push(message),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
liveEventSubjects[0].next({type: 'welcome'} as LiveEvent)
|
|
54
|
+
liveEventSubjects[0].next({type: 'message', id: 'e1', tags: ['s1:a']} as LiveEvent)
|
|
55
|
+
|
|
56
|
+
expect(messages).toEqual([{type: 'message', id: 'e1', tags: ['s1:a']}])
|
|
57
|
+
subscription.unsubscribe()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('reports CORS errors through onCorsError and completes without erroring', () => {
|
|
61
|
+
const onCorsError = vi.fn()
|
|
62
|
+
const error = vi.fn()
|
|
63
|
+
const complete = vi.fn()
|
|
64
|
+
observeLiveEvents(instance, {onCorsError}).subscribe({error, complete})
|
|
65
|
+
|
|
66
|
+
const corsError = new CorsOriginError({projectId: 'test'})
|
|
67
|
+
liveEventSubjects[0].error(corsError)
|
|
68
|
+
|
|
69
|
+
expect(onCorsError).toHaveBeenCalledWith(corsError)
|
|
70
|
+
expect(error).not.toHaveBeenCalled()
|
|
71
|
+
expect(complete).toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('completes without retrying on DisconnectError', async () => {
|
|
75
|
+
vi.useFakeTimers()
|
|
76
|
+
try {
|
|
77
|
+
const error = vi.fn()
|
|
78
|
+
const complete = vi.fn()
|
|
79
|
+
observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error, complete})
|
|
80
|
+
|
|
81
|
+
liveEventSubjects[0].error(new DisconnectError('Server disconnected client'))
|
|
82
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
|
|
83
|
+
|
|
84
|
+
expect(error).not.toHaveBeenCalled()
|
|
85
|
+
expect(complete).toHaveBeenCalled()
|
|
86
|
+
expect(events).toHaveBeenCalledTimes(1)
|
|
87
|
+
} finally {
|
|
88
|
+
vi.useRealTimers()
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('completes without retrying on a 4xx connection rejection', async () => {
|
|
93
|
+
vi.useFakeTimers()
|
|
94
|
+
try {
|
|
95
|
+
const error = vi.fn()
|
|
96
|
+
const complete = vi.fn()
|
|
97
|
+
observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error, complete})
|
|
98
|
+
|
|
99
|
+
// The server rejected the connection with a 401 (e.g. expired token) —
|
|
100
|
+
// it will keep rejecting, so retrying would reconnect once per second
|
|
101
|
+
// forever
|
|
102
|
+
liveEventSubjects[0].error(
|
|
103
|
+
new ConnectionFailedError('EventSource connection failed', {status: 401}),
|
|
104
|
+
)
|
|
105
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
|
|
106
|
+
|
|
107
|
+
expect(error).not.toHaveBeenCalled()
|
|
108
|
+
expect(complete).toHaveBeenCalled()
|
|
109
|
+
expect(events).toHaveBeenCalledTimes(1)
|
|
110
|
+
} finally {
|
|
111
|
+
vi.useRealTimers()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('retries a connection failure without a status (transient network failure)', async () => {
|
|
116
|
+
vi.useFakeTimers()
|
|
117
|
+
try {
|
|
118
|
+
const error = vi.fn()
|
|
119
|
+
const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error})
|
|
120
|
+
|
|
121
|
+
// No status means the failure could be transient (native EventSource
|
|
122
|
+
// exposes no status) — reconnecting is correct here
|
|
123
|
+
liveEventSubjects[0].error(new ConnectionFailedError('EventSource connection failed'))
|
|
124
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY)
|
|
125
|
+
|
|
126
|
+
expect(events).toHaveBeenCalledTimes(2)
|
|
127
|
+
expect(error).not.toHaveBeenCalled()
|
|
128
|
+
subscription.unsubscribe()
|
|
129
|
+
} finally {
|
|
130
|
+
vi.useRealTimers()
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('retries the connection after server-initiated errors', async () => {
|
|
135
|
+
vi.useFakeTimers()
|
|
136
|
+
try {
|
|
137
|
+
const error = vi.fn()
|
|
138
|
+
const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error})
|
|
139
|
+
expect(events).toHaveBeenCalledTimes(1)
|
|
140
|
+
|
|
141
|
+
liveEventSubjects[0].error(new Error('ChannelError'))
|
|
142
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY)
|
|
143
|
+
|
|
144
|
+
expect(events).toHaveBeenCalledTimes(2)
|
|
145
|
+
expect(error).not.toHaveBeenCalled()
|
|
146
|
+
subscription.unsubscribe()
|
|
147
|
+
} finally {
|
|
148
|
+
vi.useRealTimers()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionFailedError,
|
|
3
|
+
CorsOriginError,
|
|
4
|
+
DisconnectError,
|
|
5
|
+
type LiveEventMessage,
|
|
6
|
+
} from '@sanity/client'
|
|
7
|
+
import {catchError, defer, EMPTY, filter, type Observable, retry, switchMap} from 'rxjs'
|
|
8
|
+
|
|
9
|
+
import {type DocumentResource} from '../config/sanityConfig'
|
|
10
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
11
|
+
import {getClientState} from './clientStore'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* All live-events subscriptions must use identical connection options
|
|
15
|
+
* (API version, request tag, `includeDrafts`): `@sanity/client` caches the
|
|
16
|
+
* underlying EventSource per URL + options, so divergent options would
|
|
17
|
+
* silently split what should be a single shared connection.
|
|
18
|
+
*/
|
|
19
|
+
const LIVE_EVENTS_API_VERSION = 'v2025-05-06'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Delay before re-establishing the live events connection after a
|
|
23
|
+
* server-initiated error (e.g. ChannelError, MessageError). Live errors are
|
|
24
|
+
* retried rather than surfaced because an errored live connection would
|
|
25
|
+
* otherwise poison the consuming store. Plain connection drops never reach
|
|
26
|
+
* this — they are reconnected inside `@sanity/client`.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export const LIVE_EVENTS_RETRY_DELAY = 1000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Options for {@link observeLiveEvents}.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export interface ObserveLiveEventsOptions {
|
|
37
|
+
/** Resource to scope the underlying client and live connection to. */
|
|
38
|
+
resource?: DocumentResource
|
|
39
|
+
/**
|
|
40
|
+
* Called when the connection fails due to a CORS misconfiguration. The
|
|
41
|
+
* error is swallowed (the stream never errors; it idles until a new client
|
|
42
|
+
* is emitted) so consumers can surface it as store state instead of a
|
|
43
|
+
* stream error.
|
|
44
|
+
*/
|
|
45
|
+
onCorsError: (error: unknown) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Emits Live Content API messages for a dataset so consumers can invalidate
|
|
50
|
+
* fetched data via sync tags.
|
|
51
|
+
*
|
|
52
|
+
* All consumers observe the same underlying EventSource connection:
|
|
53
|
+
* `@sanity/client` caches the live-events stream per URL and connection
|
|
54
|
+
* options, and every subscription made through this function uses the same
|
|
55
|
+
* API version and request tag.
|
|
56
|
+
*
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
export function observeLiveEvents(
|
|
60
|
+
instance: SanityInstance,
|
|
61
|
+
{resource, onCorsError}: ObserveLiveEventsOptions,
|
|
62
|
+
): Observable<LiveEventMessage> {
|
|
63
|
+
return getClientState(instance, {
|
|
64
|
+
apiVersion: LIVE_EVENTS_API_VERSION,
|
|
65
|
+
resource,
|
|
66
|
+
}).observable.pipe(
|
|
67
|
+
switchMap((client) =>
|
|
68
|
+
defer(() =>
|
|
69
|
+
client.live.events({includeDrafts: !!client.config().token, tag: 'live-events'}),
|
|
70
|
+
).pipe(
|
|
71
|
+
catchError((error) => {
|
|
72
|
+
if (error instanceof CorsOriginError) {
|
|
73
|
+
// Swallow CORS errors without bubbling up so that they can be
|
|
74
|
+
// handled by the consumer (e.g. shown via the Cors Error component)
|
|
75
|
+
onCorsError(error)
|
|
76
|
+
return EMPTY
|
|
77
|
+
}
|
|
78
|
+
if (error instanceof DisconnectError) {
|
|
79
|
+
// The server explicitly told this client to stop reconnecting —
|
|
80
|
+
// end live updates without erroring (consumers keep serving data,
|
|
81
|
+
// they just stop receiving live invalidation)
|
|
82
|
+
return EMPTY
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
error instanceof ConnectionFailedError &&
|
|
86
|
+
typeof error.status === 'number' &&
|
|
87
|
+
error.status >= 400 &&
|
|
88
|
+
error.status < 500
|
|
89
|
+
) {
|
|
90
|
+
// The server rejected the connection with a 4xx (e.g. a 401 from
|
|
91
|
+
// an expired token) — it will keep rejecting, so retrying would
|
|
92
|
+
// reconnect once per second forever. End live updates like a
|
|
93
|
+
// DisconnectError. A ConnectionFailedError without a status stays
|
|
94
|
+
// retryable: it may be a transient network failure.
|
|
95
|
+
return EMPTY
|
|
96
|
+
}
|
|
97
|
+
throw error
|
|
98
|
+
}),
|
|
99
|
+
// Server-initiated live errors (e.g. ChannelError, MessageError) are
|
|
100
|
+
// retried, not surfaced. Dropped connections are already reconnected
|
|
101
|
+
// inside @sanity/client.
|
|
102
|
+
retry({delay: LIVE_EVENTS_RETRY_DELAY}),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
filter((e): e is LiveEventMessage => e.type === 'message'),
|
|
106
|
+
)
|
|
107
|
+
}
|
package/src/document/actions.ts
CHANGED
|
@@ -68,6 +68,21 @@ export interface EditDocumentAction<
|
|
|
68
68
|
> extends DocumentHandle<TDocumentType, TDataset, TProjectId> {
|
|
69
69
|
type: 'document.edit'
|
|
70
70
|
patches?: PatchOperations[]
|
|
71
|
+
/**
|
|
72
|
+
* When `true`, the given `patches` are forwarded verbatim to the server and
|
|
73
|
+
* applied as-is locally, instead of being re-derived by diffing document
|
|
74
|
+
* snapshots. This preserves the operational intent of the patches (e.g.
|
|
75
|
+
* keyed array inserts/unsets), which lets concurrent edits from other
|
|
76
|
+
* clients interleave coherently instead of being overwritten by
|
|
77
|
+
* position-based patches computed from a stale snapshot.
|
|
78
|
+
*
|
|
79
|
+
* Use this when the patches were produced by an editor that tracks its own
|
|
80
|
+
* operations (e.g. a collaborative text editor). During a rebase onto a
|
|
81
|
+
* changed remote document, preserved patches are re-applied operationally;
|
|
82
|
+
* if a patch can no longer apply, the transaction is skipped and reported
|
|
83
|
+
* as a `rebase-error` document event.
|
|
84
|
+
*/
|
|
85
|
+
preserveOperations?: boolean
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/**
|
|
@@ -201,12 +216,25 @@ function convertSanityMutatePatch(
|
|
|
201
216
|
})
|
|
202
217
|
}
|
|
203
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Options for creating an `EditDocumentAction`.
|
|
221
|
+
* @beta
|
|
222
|
+
*/
|
|
223
|
+
export interface EditDocumentOptions {
|
|
224
|
+
/**
|
|
225
|
+
* Forward the given patches verbatim instead of re-deriving them by
|
|
226
|
+
* diffing document snapshots. See {@link EditDocumentAction.preserveOperations}.
|
|
227
|
+
*/
|
|
228
|
+
preserveOperations?: boolean
|
|
229
|
+
}
|
|
230
|
+
|
|
204
231
|
/**
|
|
205
232
|
* Creates an `EditDocumentAction` object with patches for modifying a document.
|
|
206
233
|
* Accepts patches in either the standard `PatchOperations` format or as a `SanityMutatePatchMutation` from `@sanity/mutate`.
|
|
207
234
|
*
|
|
208
235
|
* @param doc - A handle uniquely identifying the document to be edited.
|
|
209
236
|
* @param sanityMutatePatch - A patch mutation object from `@sanity/mutate`.
|
|
237
|
+
* @param options - Options controlling how the patches are processed.
|
|
210
238
|
* @returns An `EditDocumentAction` object ready for dispatch.
|
|
211
239
|
* @beta
|
|
212
240
|
*/
|
|
@@ -217,12 +245,14 @@ export function editDocument<
|
|
|
217
245
|
>(
|
|
218
246
|
doc: DocumentHandle<TDocumentType, TDataset, TProjectId>,
|
|
219
247
|
sanityMutatePatch: SanityMutatePatchMutation,
|
|
248
|
+
options?: EditDocumentOptions,
|
|
220
249
|
): EditDocumentAction<TDocumentType, TDataset, TProjectId>
|
|
221
250
|
/**
|
|
222
251
|
* Creates an `EditDocumentAction` object with patches for modifying a document.
|
|
223
252
|
*
|
|
224
253
|
* @param doc - A handle uniquely identifying the document to be edited.
|
|
225
254
|
* @param patches - A single patch operation or an array of patch operations.
|
|
255
|
+
* @param options - Options controlling how the patches are processed.
|
|
226
256
|
* @returns An `EditDocumentAction` object ready for dispatch.
|
|
227
257
|
* @beta
|
|
228
258
|
*/
|
|
@@ -233,6 +263,7 @@ export function editDocument<
|
|
|
233
263
|
>(
|
|
234
264
|
doc: DocumentHandle<TDocumentType, TDataset, TProjectId>,
|
|
235
265
|
patches?: PatchOperations | PatchOperations[],
|
|
266
|
+
options?: EditDocumentOptions,
|
|
236
267
|
): EditDocumentAction<TDocumentType, TDataset, TProjectId>
|
|
237
268
|
/**
|
|
238
269
|
* Creates an `EditDocumentAction` object with patches for modifying a document.
|
|
@@ -240,6 +271,7 @@ export function editDocument<
|
|
|
240
271
|
*
|
|
241
272
|
* @param doc - A handle uniquely identifying the document to be edited.
|
|
242
273
|
* @param patches - Patches in various formats (`PatchOperations`, `PatchOperations[]`, or `SanityMutatePatchMutation`).
|
|
274
|
+
* @param options - Options controlling how the patches are processed.
|
|
243
275
|
* @returns An `EditDocumentAction` object ready for dispatch.
|
|
244
276
|
*/
|
|
245
277
|
export function editDocument<
|
|
@@ -249,8 +281,10 @@ export function editDocument<
|
|
|
249
281
|
>(
|
|
250
282
|
doc: DocumentHandle<TDocumentType, TDataset, TProjectId>,
|
|
251
283
|
patches?: PatchOperations | PatchOperations[] | SanityMutatePatchMutation,
|
|
284
|
+
options?: EditDocumentOptions,
|
|
252
285
|
): EditDocumentAction<TDocumentType, TDataset, TProjectId> {
|
|
253
286
|
const effectiveDocumentId = getEffectiveDocumentId(doc)
|
|
287
|
+
const preserveOperations = options?.preserveOperations && {preserveOperations: true as const}
|
|
254
288
|
|
|
255
289
|
if (isSanityMutatePatch(patches)) {
|
|
256
290
|
const converted = convertSanityMutatePatch(patches) ?? []
|
|
@@ -259,6 +293,7 @@ export function editDocument<
|
|
|
259
293
|
type: 'document.edit',
|
|
260
294
|
documentId: effectiveDocumentId,
|
|
261
295
|
patches: converted,
|
|
296
|
+
...preserveOperations,
|
|
262
297
|
}
|
|
263
298
|
}
|
|
264
299
|
|
|
@@ -267,6 +302,7 @@ export function editDocument<
|
|
|
267
302
|
type: 'document.edit',
|
|
268
303
|
documentId: effectiveDocumentId,
|
|
269
304
|
...(patches && {patches: Array.isArray(patches) ? patches : [patches]}),
|
|
305
|
+
...preserveOperations,
|
|
270
306
|
}
|
|
271
307
|
}
|
|
272
308
|
|