@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.
Files changed (41) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +5 -4
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
  3. package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
  4. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  5. package/dist/_chunks-es/version.js +1 -1
  6. package/dist/index.d.ts +69 -4
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +79 -32
  9. package/dist/index.js.map +1 -1
  10. package/package.json +12 -12
  11. package/src/_exports/index.ts +2 -0
  12. package/src/auth/authStore.test.ts +1 -1
  13. package/src/auth/handleAuthCallback.test.ts +1 -1
  14. package/src/auth/refreshStampedToken.test.ts +199 -477
  15. package/src/auth/refreshStampedToken.ts +67 -195
  16. package/src/client/liveEvents.test.ts +151 -0
  17. package/src/client/liveEvents.ts +107 -0
  18. package/src/document/actions.ts +36 -0
  19. package/src/document/documentStore.test.ts +132 -1
  20. package/src/document/documentStore.ts +8 -0
  21. package/src/document/events.ts +32 -0
  22. package/src/document/listen.ts +8 -0
  23. package/src/document/processActions/edit.ts +19 -14
  24. package/src/document/processActions/shared.ts +55 -8
  25. package/src/document/processActions.test.ts +145 -0
  26. package/src/document/reducers.test.ts +396 -0
  27. package/src/document/reducers.ts +62 -1
  28. package/src/document/sharedListener.test.ts +1 -0
  29. package/src/document/sharedListener.ts +2 -0
  30. package/src/query/queryStore.test.ts +232 -38
  31. package/src/query/queryStore.ts +47 -47
  32. package/src/query/reducers.test.ts +0 -42
  33. package/src/query/reducers.ts +0 -16
  34. package/src/releases/getPerspectiveState.test.ts +3 -8
  35. package/src/releases/getPerspectiveState.ts +6 -19
  36. package/src/releases/observeReleases.test.ts +127 -0
  37. package/src/releases/observeReleases.ts +102 -0
  38. package/src/releases/releasesStore.test.ts +51 -53
  39. package/src/releases/releasesStore.ts +32 -32
  40. package/src/releases/utils/sortReleases.test.ts +36 -0
  41. 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
- storeState,
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
- (prev, curr) =>
168
- prev.authState.type === curr.authState.type &&
169
- prev.authState.token === curr.authState.token && // Only care about token for distinctness here
170
- prev.dashboardContext === curr.dashboardContext,
171
- ), // Make distinctness check explicit
172
- filter((storeState) => storeState.authState.token.includes('-st')), // Ensure we only try to refresh stamped tokens
173
- exhaustMap((storeState) => {
174
- // USE exhaustMap instead of switchMap
175
- // Create a function that performs a single refresh and updates state/storage
176
- const performRefresh = async () => {
177
- // Read the latest token directly from state inside refresh
178
- const currentState = state.get()
179
- if (currentState.authState.type !== AuthStateType.LOGGED_IN) {
180
- logger.debug('Token refresh aborted - user logged out')
181
- throw new Error('User logged out before refresh could complete') // Abort refresh
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
- }).pipe(
267
- takeWhile(() => state.get().authState.type === AuthStateType.LOGGED_IN),
268
- map((response: {token: string}) => ({token: response.token})),
269
- )
270
- }
115
+ }
271
116
 
272
- // If not in dashboard context, use lock-based refresh
273
- return from(acquireTokenRefreshLock(performRefresh, storageArea, storageKey)).pipe(
274
- filter((hasLock) => hasLock),
275
- map(() => {
276
- const currentState = state.get().authState
277
- if (currentState.type !== AuthStateType.LOGGED_IN) {
278
- throw new Error('User logged out before refresh could complete')
279
- }
280
- return {token: currentState.token} as const
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
+ }
@@ -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