@sanity/sdk 2.20.2 → 2.21.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.
@@ -10,10 +10,12 @@ import {
10
10
  type RawQueryResponse,
11
11
  type ResponseQueryOptions,
12
12
  type SanityClient,
13
+ ServerError,
13
14
  type SingleActionResult,
14
15
  type UnfilteredResponseQueryOptions,
15
16
  type WelcomeEvent,
16
17
  } from '@sanity/client'
18
+ import {makePatches, stringifyPatches} from '@sanity/diff-match-patch'
17
19
  import {diffValue} from '@sanity/diff-patch'
18
20
  import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
19
21
  import {type Mutation, type SanityDocument} from '@sanity/types'
@@ -55,6 +57,7 @@ import {
55
57
  } from './documentStore'
56
58
  import {
57
59
  type ActionErrorEvent,
60
+ type DocumentEvent,
58
61
  type DocumentRemotePatchesEvent,
59
62
  type TransactionRevertedEvent,
60
63
  } from './events'
@@ -786,7 +789,13 @@ it('reverts failed outgoing transaction locally', async () => {
786
789
  const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
787
790
  vi.mocked(client.action).mockImplementation(async (...args) => {
788
791
  const [, {transactionId} = {}] = args
789
- if (transactionId === 'force-revert') throw new Error('example error')
792
+ if (transactionId === 'force-revert') {
793
+ throw new ClientError({
794
+ statusCode: 400,
795
+ headers: {},
796
+ body: {error: 'Bad Request', message: 'example error'},
797
+ })
798
+ }
790
799
  return await clientActionMockImplementation(...args)
791
800
  })
792
801
 
@@ -834,7 +843,7 @@ it('reverts failed outgoing transaction locally', async () => {
834
843
 
835
844
  await expect(revertedEventPromise).resolves.toMatchObject({
836
845
  type: 'reverted',
837
- message: 'example error',
846
+ message: 'Bad Request - example error',
838
847
  outgoing: {transactionId: 'force-revert'},
839
848
  })
840
849
 
@@ -1406,6 +1415,9 @@ vi.mock('./sharedListener.ts', () => {
1406
1415
  }
1407
1416
  })
1408
1417
 
1418
+ // read through getters so a test can lengthen the submission backoff
1419
+ const submissionRetryDelay = vi.hoisted(() => ({ms: 5}))
1420
+
1409
1421
  vi.mock('./documentConstants.ts', async (importOriginal) => {
1410
1422
  const original = await importOriginal<typeof import('./documentConstants')>()
1411
1423
  return {
@@ -1416,6 +1428,12 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
1416
1428
  OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
1417
1429
  ACL_RETRY_BASE_DELAY: 0,
1418
1430
  ACL_RETRY_MAX_DELAY: 0,
1431
+ get SUBMISSION_RETRY_BASE_DELAY() {
1432
+ return submissionRetryDelay.ms
1433
+ },
1434
+ get SUBMISSION_RETRY_MAX_DELAY() {
1435
+ return submissionRetryDelay.ms
1436
+ },
1419
1437
  }
1420
1438
  })
1421
1439
 
@@ -1820,3 +1838,277 @@ beforeEach(() => {
1820
1838
  } as SanityClient
1821
1839
  client$.next(client)
1822
1840
  })
1841
+
1842
+ it('keeps edits made while the session is expired and submits them after re-authentication', async () => {
1843
+ // Same status and body the actions API returned on an expired SAML session
1844
+ const sessionExpired = new ClientError({
1845
+ statusCode: 401,
1846
+ headers: {},
1847
+ body: {statusCode: 401, error: 'Unauthorized', message: 'Session not found'},
1848
+ })
1849
+ let sessionValid = true
1850
+ let rejectedAttempts = 0
1851
+ const actualAction = vi.mocked(client.action).getMockImplementation()!
1852
+ vi.mocked(client.action).mockImplementation(async (...args) => {
1853
+ if (!sessionValid) {
1854
+ rejectedAttempts++
1855
+ throw sessionExpired
1856
+ }
1857
+ return await actualAction(...args)
1858
+ })
1859
+
1860
+ const events: DocumentEvent[] = []
1861
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
1862
+ resource,
1863
+ eventHandler: (e) => events.push(e),
1864
+ })
1865
+
1866
+ const doc = createDocumentHandle({
1867
+ documentId: DocumentId('session-expiry'),
1868
+ documentType: 'article',
1869
+ })
1870
+ const state = getDocumentState<TestDocument>(instance, doc)
1871
+ const syncStatus = getDocumentSyncStatus(instance, doc)
1872
+ const unsubscribe = state.subscribe()
1873
+ const unsubscribeSync = syncStatus.subscribe()
1874
+
1875
+ await applyDocumentActions(instance, {
1876
+ actions: [createDocument(doc), editDocument(doc, {set: {title: 'saved'}})],
1877
+ resource,
1878
+ }).then((r) => r.submitted())
1879
+
1880
+ // the session expires while the user keeps typing, one batch per flush
1881
+ sessionValid = false
1882
+ const results = ['saved, one', 'saved, one two', 'saved, one two three'].map((title) =>
1883
+ applyDocumentActions(instance, {
1884
+ actions: [editDocument(doc, {set: {title}})],
1885
+ resource,
1886
+ disableBatching: true,
1887
+ }),
1888
+ )
1889
+ await expect.poll(() => rejectedAttempts).toBeGreaterThanOrEqual(3)
1890
+
1891
+ // nothing is reverted: the edits stay on screen and the document reports
1892
+ // that it is not in sync
1893
+ expect(events.filter((e) => e.type === 'reverted')).toEqual([])
1894
+ expect(events.filter((e) => e.type === 'submission-failed').slice(0, 3)).toMatchObject([
1895
+ {message: 'Unauthorized - Session not found', attempt: 1},
1896
+ {message: 'Unauthorized - Session not found', attempt: 2},
1897
+ {message: 'Unauthorized - Session not found', attempt: 3},
1898
+ ])
1899
+ expect(state.getCurrent()?.title).toBe('saved, one two three')
1900
+ expect(syncStatus.getCurrent()).toBe(false)
1901
+
1902
+ // the user re-authenticates and every edit lands
1903
+ sessionValid = true
1904
+ await Promise.all(results.map((r) => r.then((x) => x.submitted())))
1905
+ expect(state.getCurrent()?.title).toBe('saved, one two three')
1906
+ expect(syncStatus.getCurrent()).toBe(true)
1907
+ expect(events.filter((e) => e.type === 'reverted')).toEqual([])
1908
+
1909
+ unsubscribeSync()
1910
+ unsubscribe()
1911
+ unsubscribeEvents()
1912
+ vi.mocked(client.action).mockImplementation(actualAction)
1913
+ })
1914
+
1915
+ it('retries a submission that failed with a server error or a dropped connection', async () => {
1916
+ const failures: unknown[] = [
1917
+ new ServerError({statusCode: 503, headers: {}, body: {}}),
1918
+ new Error('Request error while attempting to reach https://p.api.sanity.io'),
1919
+ ]
1920
+ const actualAction = vi.mocked(client.action).getMockImplementation()!
1921
+ vi.mocked(client.action).mockImplementation(async (...args) => {
1922
+ const [, {transactionId} = {}] = args
1923
+ if (transactionId === 'flaky' && failures.length) throw failures.shift()
1924
+ return await actualAction(...args)
1925
+ })
1926
+
1927
+ const doc = createDocumentHandle({
1928
+ documentId: DocumentId('flaky-network'),
1929
+ documentType: 'article',
1930
+ })
1931
+ const state = getDocumentState<TestDocument>(instance, doc)
1932
+ const unsubscribe = state.subscribe()
1933
+
1934
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], resource}).then((r) =>
1935
+ r.submitted(),
1936
+ )
1937
+ await applyDocumentActions(instance, {
1938
+ actions: [editDocument(doc, {set: {title: 'kept'}})],
1939
+ transactionId: 'flaky',
1940
+ resource,
1941
+ }).then((r) => r.submitted())
1942
+
1943
+ expect(failures).toEqual([])
1944
+ expect(state.getCurrent()?.title).toBe('kept')
1945
+
1946
+ unsubscribe()
1947
+ vi.mocked(client.action).mockImplementation(actualAction)
1948
+ })
1949
+
1950
+ it('treats transactionAlreadyExistsError on a retry as an ack of the earlier attempt', async () => {
1951
+ // the first attempt is committed but its response is lost; the retry then
1952
+ // collides with the transaction ID the server already recorded
1953
+ const actualAction = vi.mocked(client.action).getMockImplementation()!
1954
+ let attempts = 0
1955
+ vi.mocked(client.action).mockImplementation(async (...args) => {
1956
+ const [, {transactionId} = {}] = args
1957
+ if (transactionId !== 'lost-response') return await actualAction(...args)
1958
+ attempts++
1959
+ if (attempts === 1) {
1960
+ await actualAction(...args)
1961
+ throw new Error('Request error while attempting to reach https://p.api.sanity.io')
1962
+ }
1963
+ throw new ClientError({
1964
+ statusCode: 409,
1965
+ headers: {},
1966
+ body: {
1967
+ error: {
1968
+ type: 'transactionAlreadyExistsError',
1969
+ description: 'The transaction ID "lost-response" already exists',
1970
+ },
1971
+ },
1972
+ })
1973
+ })
1974
+
1975
+ const reverted: TransactionRevertedEvent[] = []
1976
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
1977
+ resource,
1978
+ eventHandler: (e) => {
1979
+ if (e.type === 'reverted') reverted.push(e)
1980
+ },
1981
+ })
1982
+
1983
+ const doc = createDocumentHandle({
1984
+ documentId: DocumentId('lost-response'),
1985
+ documentType: 'article',
1986
+ })
1987
+ const state = getDocumentState<TestDocument>(instance, doc)
1988
+ const unsubscribe = state.subscribe()
1989
+
1990
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], resource}).then((r) =>
1991
+ r.submitted(),
1992
+ )
1993
+ await applyDocumentActions(instance, {
1994
+ actions: [editDocument(doc, {set: {title: 'committed once'}})],
1995
+ transactionId: 'lost-response',
1996
+ resource,
1997
+ }).then((r) => r.submitted())
1998
+
1999
+ expect(attempts).toBe(2)
2000
+ expect(reverted).toEqual([])
2001
+ expect(state.getCurrent()?.title).toBe('committed once')
2002
+
2003
+ unsubscribe()
2004
+ unsubscribeEvents()
2005
+ vi.mocked(client.action).mockImplementation(actualAction)
2006
+ })
2007
+
2008
+ it('resubmits held text edits as soon as the credentials change, in order and once each', async () => {
2009
+ // The sequence from a writer whose session expired mid-edit: a text field
2010
+ // written with operation-preserving diffMatchPatch edits (what
2011
+ // @portabletext/plugin-sdk-value sends), every write on the old token
2012
+ // rejected with a 401, then a new token from logging in again in another tab
2013
+ const client$ = (getClientState as () => StateSource<SanityClient>)()
2014
+ .observable as ReplaySubject<SanityClient>
2015
+ const actualAction = vi.mocked(client.action).getMockImplementation()!
2016
+ let sessionValid = true
2017
+ let rejectedAttempts = 0
2018
+ vi.mocked(client.action).mockImplementation(async (...args) => {
2019
+ if (sessionValid) return await actualAction(...args)
2020
+ rejectedAttempts++
2021
+ throw new ClientError({
2022
+ statusCode: 401,
2023
+ headers: {},
2024
+ body: {statusCode: 401, error: 'Unauthorized', message: 'Session not found'},
2025
+ })
2026
+ })
2027
+ // the client store builds a new client when the token changes; it carries
2028
+ // the renewed token, so the server accepts its writes
2029
+ const renewedClient = {
2030
+ ...client,
2031
+ observable: {
2032
+ ...client.observable,
2033
+ action: (...args: Parameters<typeof actualAction>) => from(actualAction(...args)),
2034
+ },
2035
+ } as SanityClient
2036
+
2037
+ const reverted: TransactionRevertedEvent[] = []
2038
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
2039
+ resource,
2040
+ eventHandler: (e) => {
2041
+ if (e.type === 'reverted') reverted.push(e)
2042
+ },
2043
+ })
2044
+
2045
+ const doc = createDocumentHandle({
2046
+ documentId: DocumentId('renewed-session'),
2047
+ documentType: 'article',
2048
+ })
2049
+ const state = getDocumentState<TestDocument>(instance, doc)
2050
+ const unsubscribe = state.subscribe()
2051
+
2052
+ const typeText = (before: string, after: string) =>
2053
+ applyDocumentActions(instance, {
2054
+ actions: [
2055
+ {
2056
+ ...editDocument(doc, {
2057
+ diffMatchPatch: {title: stringifyPatches(makePatches(before, after))},
2058
+ }),
2059
+ preserveOperations: true,
2060
+ },
2061
+ ],
2062
+ resource,
2063
+ disableBatching: true,
2064
+ })
2065
+
2066
+ submissionRetryDelay.ms = 60_000
2067
+ try {
2068
+ await applyDocumentActions(instance, {
2069
+ actions: [createDocument(doc), editDocument(doc, {set: {title: 'Start:'}})],
2070
+ resource,
2071
+ }).then((r) => r.submitted())
2072
+
2073
+ sessionValid = false
2074
+ const results = [
2075
+ typeText('Start:', 'Start: the'),
2076
+ typeText('Start: the', 'Start: the mayor'),
2077
+ typeText('Start: the mayor', 'Start: the mayor said'),
2078
+ ]
2079
+
2080
+ // the first write is rejected and the 60s backoff holds its retry, while
2081
+ // every edit stays applied locally
2082
+ await expect.poll(() => rejectedAttempts).toBe(1)
2083
+ await new Promise((resolve) => setTimeout(resolve, 100))
2084
+ expect(rejectedAttempts).toBe(1)
2085
+ expect(state.getCurrent()?.title).toBe('Start: the mayor said')
2086
+
2087
+ // logging in again: the held write and the queued ones land long before
2088
+ // the backoff would have elapsed
2089
+ client$.next(renewedClient)
2090
+ await Promise.race([
2091
+ Promise.all(results.map((r) => r.then((x) => x.submitted()))),
2092
+ new Promise((_, reject) =>
2093
+ setTimeout(() => reject(new Error('edits were not resubmitted on the new client')), 2000),
2094
+ ),
2095
+ ])
2096
+
2097
+ expect(rejectedAttempts).toBe(1)
2098
+ expect(reverted).toEqual([])
2099
+ expect(state.getCurrent()?.title).toBe('Start: the mayor said')
2100
+ // the server copy has each edit applied exactly once
2101
+ await expect(
2102
+ client.fetch<string>(
2103
+ '*[_id == $id][0].title',
2104
+ {id: getDraftId(DocumentId('renewed-session'))},
2105
+ {filterResponse: true},
2106
+ ),
2107
+ ).resolves.toBe('Start: the mayor said')
2108
+ } finally {
2109
+ submissionRetryDelay.ms = 5
2110
+ unsubscribe()
2111
+ unsubscribeEvents()
2112
+ vi.mocked(client.action).mockImplementation(actualAction)
2113
+ }
2114
+ })
@@ -1,4 +1,10 @@
1
- import {type Action, ClientError, CorsOriginError, type Mutation} from '@sanity/client'
1
+ import {
2
+ type Action,
3
+ ClientError,
4
+ CorsOriginError,
5
+ type Mutation,
6
+ type SanityClient,
7
+ } from '@sanity/client'
2
8
  import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
3
9
  import {jsonMatch} from '@sanity/json-match'
4
10
  import {type SanityDocument} from 'groq'
@@ -6,6 +12,7 @@ import {type ExprNode} from 'groq-js'
6
12
  import {
7
13
  catchError,
8
14
  concatMap,
15
+ defer,
9
16
  distinctUntilChanged,
10
17
  EMPTY,
11
18
  filter,
@@ -17,6 +24,7 @@ import {
17
24
  Observable,
18
25
  of,
19
26
  pairwise,
27
+ race,
20
28
  retry,
21
29
  startWith,
22
30
  Subject,
@@ -54,6 +62,8 @@ import {
54
62
  INITIAL_OUTGOING_THROTTLE_TIME,
55
63
  OUT_OF_SYNC_RETRY_BASE_DELAY,
56
64
  OUT_OF_SYNC_RETRY_MAX_DELAY,
65
+ SUBMISSION_RETRY_BASE_DELAY,
66
+ SUBMISSION_RETRY_MAX_DELAY,
57
67
  } from './documentConstants'
58
68
  import {
59
69
  type DocumentEvent,
@@ -85,6 +95,7 @@ import {
85
95
  type UnverifiedDocumentRevision,
86
96
  } from './reducers'
87
97
  import {createFetchDocument, createSharedListener, type SharedListener} from './sharedListener'
98
+ import {classifySubmissionError} from './submissionErrors'
88
99
 
89
100
  export interface DocumentStoreState {
90
101
  documentStates: {[TDocumentId in string]?: DocumentState}
@@ -393,12 +404,39 @@ const subscribeToQueuedAndApplyNextTransaction = ({
393
404
  .subscribe({error: (error) => state.set('setError', {error})})
394
405
  }
395
406
 
407
+ const getErrorMessage = (error: unknown) =>
408
+ error instanceof Error ? error.message : 'Request failed'
409
+
410
+ const submitOutgoingTransaction = (client: SanityClient, outgoing: OutgoingTransaction) => {
411
+ // liveEdit transactions route to the mutations API; everything else routes
412
+ // to the actions API. processActions rejects transactions that mix the two,
413
+ // and reducers won't batch across that boundary, so a batch is always
414
+ // entirely liveEdit or entirely not.
415
+ if (outgoing.actions.some((action) => !isReleaseAction(action) && action.liveEdit)) {
416
+ return client.observable.mutate(outgoing.outgoingMutations as Mutation[], {
417
+ transactionId: outgoing.transactionId,
418
+ visibility: 'async',
419
+ returnDocuments: false,
420
+ returnFirst: false,
421
+ tag: 'document.mutate',
422
+ skipCrossDatasetReferenceValidation: true,
423
+ })
424
+ }
425
+
426
+ return client.observable.action(outgoing.outgoingActions as Action[], {
427
+ transactionId: outgoing.transactionId,
428
+ skipCrossDatasetReferenceValidation: true,
429
+ tag: 'document.action',
430
+ })
431
+ }
432
+
396
433
  const subscribeToAppliedAndSubmitNextTransaction = ({
397
434
  state,
398
435
  instance,
399
436
  key: {resource},
400
437
  }: StoreContext<DocumentStoreState, BoundResourceKey>) => {
401
438
  const {events} = state.get()
439
+ const clients$ = getClientState(instance, {apiVersion: API_VERSION, resource}).observable
402
440
 
403
441
  return state.observable
404
442
  .pipe(
@@ -417,51 +455,59 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
417
455
  tap((next) => state.set('transitionAppliedTransactionsToOutgoing', next)),
418
456
  map((s) => s.outgoing),
419
457
  distinctUntilChanged(),
420
- withLatestFrom(
421
- getClientState(instance, {
422
- apiVersion: API_VERSION,
423
- resource,
424
- }).observable,
425
- ),
426
- concatMap(([outgoing, client]) => {
458
+ withLatestFrom(clients$),
459
+ concatMap(([outgoing, initialClient]) => {
427
460
  if (!outgoing) return EMPTY
428
461
 
429
- const revertOnError = catchError((error: unknown) => {
430
- state.set('revertOutgoingTransaction', revertOutgoingTransaction)
431
- const message = error instanceof Error ? error.message : 'Request failed'
432
- events.next({type: 'reverted', message, outgoing, error})
433
- return EMPTY
434
- })
435
-
436
- const toResult = map((result: unknown) => ({
437
- result: result as DocumentTransactionSubmissionResult,
438
- outgoing,
439
- }))
440
-
441
- // liveEdit transactions route to the mutations API; everything else routes
442
- // to the actions API. processActions rejects transactions that mix the two,
443
- // and reducers won't batch across that boundary, so a batch is always
444
- // entirely liveEdit or entirely not.
445
- if (outgoing.actions.some((action) => !isReleaseAction(action) && action.liveEdit)) {
446
- return client.observable
447
- .mutate(outgoing.outgoingMutations as Mutation[], {
448
- transactionId: outgoing.transactionId,
449
- visibility: 'async',
450
- returnDocuments: false,
451
- returnFirst: false,
452
- tag: 'document.mutate',
453
- skipCrossDatasetReferenceValidation: true,
454
- })
455
- .pipe(revertOnError, toResult)
456
- }
462
+ // the client that made the latest attempt. the client store rebuilds
463
+ // its clients when the token changes, so a different client means
464
+ // different credentials and a retry after a 401 can go right away
465
+ let attemptClient: SanityClient | undefined
457
466
 
458
- return client.observable
459
- .action(outgoing.outgoingActions as Action[], {
460
- transactionId: outgoing.transactionId,
461
- skipCrossDatasetReferenceValidation: true,
462
- tag: 'document.action',
463
- })
464
- .pipe(revertOnError, toResult)
467
+ return defer(() => (attemptClient ? clients$.pipe(first()) : of(initialClient))).pipe(
468
+ concatMap((client) => {
469
+ attemptClient = client
470
+ return submitOutgoingTransaction(client, outgoing)
471
+ }),
472
+ retry({
473
+ delay: (error: unknown, retryCount) => {
474
+ if (classifySubmissionError(error) !== 'retry') return throwError(() => error)
475
+
476
+ // keep the transaction and its optimistic local state. the
477
+ // document stays out of sync (see `getDocumentSyncStatus`) and
478
+ // later edits queue behind this transaction until it lands
479
+ events.next({
480
+ type: 'submission-failed',
481
+ message: getErrorMessage(error),
482
+ error,
483
+ outgoing,
484
+ attempt: retryCount,
485
+ })
486
+ const backoff = Math.min(
487
+ SUBMISSION_RETRY_BASE_DELAY * 2 ** (retryCount - 1),
488
+ SUBMISSION_RETRY_MAX_DELAY,
489
+ )
490
+ return race(
491
+ timer(backoff),
492
+ clients$.pipe(first((client) => client !== attemptClient)),
493
+ )
494
+ },
495
+ }),
496
+ catchError((error: unknown) => {
497
+ if (classifySubmissionError(error) === 'accepted') {
498
+ // an earlier attempt was committed and only its response was
499
+ // lost, so this is an ack
500
+ return of({transactionId: outgoing.transactionId, documentIds: [], results: []})
501
+ }
502
+ state.set('revertOutgoingTransaction', revertOutgoingTransaction)
503
+ events.next({type: 'reverted', message: getErrorMessage(error), outgoing, error})
504
+ return EMPTY
505
+ }),
506
+ map((result: unknown) => ({
507
+ result: result as DocumentTransactionSubmissionResult,
508
+ outgoing,
509
+ })),
510
+ )
465
511
  }),
466
512
  tap(({outgoing, result}) => {
467
513
  state.set('cleanupOutgoingTransaction', cleanupOutgoingTransaction)
@@ -14,6 +14,7 @@ export type DocumentTransactionSubmissionResult =
14
14
  export type DocumentEvent =
15
15
  | ActionErrorEvent
16
16
  | TransactionRevertedEvent
17
+ | TransactionSubmissionFailedEvent
17
18
  | TransactionAcceptedEvent
18
19
  | DocumentRebaseErrorEvent
19
20
  | DocumentEditedEvent
@@ -55,6 +56,22 @@ export interface TransactionRevertedEvent {
55
56
  error: unknown
56
57
  outgoing: OutgoingTransaction
57
58
  }
59
+ /**
60
+ * @beta
61
+ * Event emitted when submitting a transaction fails with an error the store
62
+ * retries: a network failure, a 5xx, 408 or 429 response, a 401 (for example
63
+ * an expired session), or a 409 `transactionConflictError` (contention while
64
+ * committing). The transaction and its local changes are kept
65
+ * and submitted again, so the document stays out of sync until an attempt
66
+ * succeeds. Emitted once per failed attempt; `attempt` starts at 1.
67
+ */
68
+ export interface TransactionSubmissionFailedEvent {
69
+ type: 'submission-failed'
70
+ message: string
71
+ error: unknown
72
+ outgoing: OutgoingTransaction
73
+ attempt: number
74
+ }
58
75
  /**
59
76
  * @beta
60
77
  * Event emitted when an attempt to apply local changes to a modified remote document fails.
@@ -0,0 +1,63 @@
1
+ import {ClientError, CorsOriginError, ServerError} from '@sanity/client'
2
+ import {describe, expect, it} from 'vitest'
3
+
4
+ import {classifySubmissionError} from './submissionErrors'
5
+
6
+ const clientError = (statusCode: number, body: unknown = {}) =>
7
+ new ClientError({statusCode, headers: {}, body})
8
+
9
+ describe('classifySubmissionError', () => {
10
+ it('retries an expired or missing session', () => {
11
+ expect(
12
+ classifySubmissionError(
13
+ clientError(401, {statusCode: 401, error: 'Unauthorized', message: 'Session not found'}),
14
+ ),
15
+ ).toBe('retry')
16
+ })
17
+
18
+ it('retries server errors, timeouts, rate limits and network failures', () => {
19
+ expect(classifySubmissionError(new ServerError({statusCode: 503, headers: {}, body: {}}))).toBe(
20
+ 'retry',
21
+ )
22
+ expect(classifySubmissionError(clientError(408))).toBe('retry')
23
+ expect(classifySubmissionError(clientError(429))).toBe('retry')
24
+ expect(classifySubmissionError(new Error('Request error while attempting to reach'))).toBe(
25
+ 'retry',
26
+ )
27
+ expect(classifySubmissionError('not an error')).toBe('retry')
28
+ })
29
+
30
+ it('retries a transaction that hit contention while committing', () => {
31
+ expect(
32
+ classifySubmissionError(
33
+ clientError(409, {
34
+ error: {type: 'transactionConflictError', description: 'transaction conflict'},
35
+ }),
36
+ ),
37
+ ).toBe('retry')
38
+ })
39
+
40
+ it('treats an already recorded transaction ID as an ack', () => {
41
+ expect(
42
+ classifySubmissionError(
43
+ clientError(409, {
44
+ error: {type: 'transactionAlreadyExistsError', description: 'already exists'},
45
+ }),
46
+ ),
47
+ ).toBe('accepted')
48
+ })
49
+
50
+ it('reverts transactions the server rejected', () => {
51
+ for (const statusCode of [400, 402, 403, 404, 410, 412, 413, 422]) {
52
+ expect(classifySubmissionError(clientError(statusCode))).toBe('revert')
53
+ }
54
+ expect(
55
+ classifySubmissionError(
56
+ clientError(409, {
57
+ error: {type: 'documentRevisionIDDoesNotMatchError', description: 'revision mismatch'},
58
+ }),
59
+ ),
60
+ ).toBe('revert')
61
+ expect(classifySubmissionError(new CorsOriginError({projectId: 'p'}))).toBe('revert')
62
+ })
63
+ })
@@ -0,0 +1,51 @@
1
+ import {ClientError, CorsOriginError} from '@sanity/client'
2
+
3
+ /**
4
+ * What the document store does with an outgoing transaction whose submission
5
+ * failed.
6
+ *
7
+ * - `retry`: the failure is about the connection, the credentials or
8
+ * contention on the server, not the transaction. Keep the transaction and
9
+ * its optimistic local state, and submit it again.
10
+ * - `accepted`: the server already recorded this transaction ID, so an earlier
11
+ * attempt was committed even though its response never arrived.
12
+ * - `revert`: the server rejected the transaction itself. Submitting it again
13
+ * cannot succeed, so drop it and rebuild local state from the server copy.
14
+ *
15
+ * @internal
16
+ */
17
+ export type SubmissionErrorClass = 'retry' | 'accepted' | 'revert'
18
+
19
+ // Mirrors Studio's `TERMINAL_COMMIT_STATUSES` (checkoutPair.ts) without 401. A
20
+ // 401 rejects the credentials (usually an expired session), not the
21
+ // transaction, so the same transaction succeeds once the user re-authenticates.
22
+ const REJECTED_TRANSACTION_STATUS_CODES = new Set([400, 402, 403, 404, 409, 410, 412, 413, 422])
23
+
24
+ function getErrorType(error: ClientError): unknown {
25
+ const details: unknown = error.details
26
+ if (details && typeof details === 'object' && 'type' in details) return details.type
27
+ return undefined
28
+ }
29
+
30
+ /**
31
+ * Classifies a failed transaction submission. Unknown failures (network
32
+ * errors, timeouts, 5xx and 429 responses, and anything that is not a
33
+ * `ClientError`) default to `retry`: wrongly retrying a rejected transaction
34
+ * leaves the document visibly unsynced with the edits still on screen, while
35
+ * wrongly reverting a transient failure silently discards the user's work.
36
+ *
37
+ * @internal
38
+ */
39
+ export function classifySubmissionError(error: unknown): SubmissionErrorClass {
40
+ if (error instanceof CorsOriginError) return 'revert'
41
+ if (!(error instanceof ClientError)) return 'retry'
42
+ if (error.statusCode === 409) {
43
+ const errorType = getErrorType(error)
44
+ if (errorType === 'transactionAlreadyExistsError') return 'accepted'
45
+ // contention while committing, not a verdict on the transaction; the
46
+ // server rolled it back, so the same transaction ID is safe to resend
47
+ if (errorType === 'transactionConflictError') return 'retry'
48
+ }
49
+ if (REJECTED_TRANSACTION_STATUS_CODES.has(error.statusCode)) return 'revert'
50
+ return 'retry'
51
+ }