@sanity/sdk 2.20.1 → 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'
@@ -357,6 +360,75 @@ it('emits remote-patches events distinguishing own and foreign transactions', as
357
360
  state2Unsubscribe()
358
361
  })
359
362
 
363
+ it('labels an echo that arrives after the last subscriber left as its own', async () => {
364
+ const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
365
+ const draftId = getDraftId(DocumentId(doc.documentId))
366
+ const state = getDocumentState(instance, doc)
367
+ const unsubscribe = state.subscribe()
368
+ await vi.waitUntil(() => state.getCurrent() !== undefined)
369
+ const previousRev = state.getCurrent()?._rev
370
+
371
+ // ack without emitting the listener echo, so the echo can be delivered later
372
+ // and out of band, which is the ordering the real API produces
373
+ const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
374
+ vi.mocked(client.action).mockImplementation(
375
+ async (_input, {transactionId = crypto.randomUUID()} = {}) => ({transactionId}),
376
+ )
377
+
378
+ const patchEvents: DocumentRemotePatchesEvent[] = []
379
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
380
+ resource,
381
+ eventHandler: (e) => {
382
+ if (e.type === 'remote-patches') patchEvents.push(e)
383
+ },
384
+ })
385
+
386
+ const edited = await applyDocumentActions(instance, {
387
+ actions: [editDocument(doc, {set: {title: 'edited while leaving'}})],
388
+ resource,
389
+ })
390
+
391
+ // the last subscriber leaves before the ack comes back, which used to delete
392
+ // the document state (and with it the memory of our own transaction) as soon
393
+ // as the ack released the transaction's subscription
394
+ unsubscribe()
395
+ await new Promise((resolve) => setTimeout(resolve, 50))
396
+ await edited.submitted()
397
+
398
+ const {events: listenerEvents} = (
399
+ createSharedListener as () => {
400
+ dispose: () => void
401
+ events: Subject<ListenEvent<SanityDocument>>
402
+ }
403
+ )()
404
+ listenerEvents.next({
405
+ type: 'mutation',
406
+ documentId: draftId,
407
+ eventId: `${edited.transactionId}#${draftId}`,
408
+ identity: 'example-user',
409
+ mutations: [{patch: {id: draftId, set: {title: 'edited while leaving'}}}],
410
+ timestamp: new Date().toISOString(),
411
+ transactionId: edited.transactionId,
412
+ transactionCurrentEvent: 1,
413
+ transactionTotalEvents: 1,
414
+ transition: 'update',
415
+ visibility: 'query',
416
+ previousRev,
417
+ resultRev: edited.transactionId,
418
+ } satisfies MutationEvent)
419
+ await vi.waitUntil(() => patchEvents.length > 0)
420
+
421
+ expect(patchEvents).toHaveLength(1)
422
+ expect(patchEvents[0]).toMatchObject({
423
+ documentId: draftId,
424
+ transactionId: edited.transactionId,
425
+ origin: 'local',
426
+ })
427
+
428
+ unsubscribeEvents()
429
+ vi.mocked(client.action).mockImplementation(clientActionMockImplementation)
430
+ })
431
+
360
432
  it('handles concurrent edits and resolves conflicts', async () => {
361
433
  const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
362
434
  const state1 = getDocumentState(instance1, doc)
@@ -717,7 +789,13 @@ it('reverts failed outgoing transaction locally', async () => {
717
789
  const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
718
790
  vi.mocked(client.action).mockImplementation(async (...args) => {
719
791
  const [, {transactionId} = {}] = args
720
- 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
+ }
721
799
  return await clientActionMockImplementation(...args)
722
800
  })
723
801
 
@@ -765,7 +843,7 @@ it('reverts failed outgoing transaction locally', async () => {
765
843
 
766
844
  await expect(revertedEventPromise).resolves.toMatchObject({
767
845
  type: 'reverted',
768
- message: 'example error',
846
+ message: 'Bad Request - example error',
769
847
  outgoing: {transactionId: 'force-revert'},
770
848
  })
771
849
 
@@ -1337,6 +1415,9 @@ vi.mock('./sharedListener.ts', () => {
1337
1415
  }
1338
1416
  })
1339
1417
 
1418
+ // read through getters so a test can lengthen the submission backoff
1419
+ const submissionRetryDelay = vi.hoisted(() => ({ms: 5}))
1420
+
1340
1421
  vi.mock('./documentConstants.ts', async (importOriginal) => {
1341
1422
  const original = await importOriginal<typeof import('./documentConstants')>()
1342
1423
  return {
@@ -1347,6 +1428,12 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
1347
1428
  OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
1348
1429
  ACL_RETRY_BASE_DELAY: 0,
1349
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
+ },
1350
1437
  }
1351
1438
  })
1352
1439
 
@@ -1751,3 +1838,277 @@ beforeEach(() => {
1751
1838
  } as SanityClient
1752
1839
  client$.next(client)
1753
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.