@sanity/sdk 3.6.0 → 3.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -186,6 +186,7 @@ export {
186
186
  type DocumentUnpublishedEvent,
187
187
  type TransactionAcceptedEvent,
188
188
  type TransactionRevertedEvent,
189
+ type TransactionSubmissionFailedEvent,
189
190
  } from '../document/events'
190
191
  export {type JsonMatch} from '../document/patchOperations'
191
192
  export {type DocumentPermissionsResult, type PermissionDeniedReason} from '../document/permissions'
@@ -542,6 +542,38 @@ describe('authStore', () => {
542
542
  expect(checkForCookieAuth).not.toHaveBeenCalled()
543
543
  })
544
544
 
545
+ it('switches to the renewed token when the Studio token source emits one', () => {
546
+ // Studio broadcasts the new token to every open tab when the user logs in
547
+ // again in any of them, so a tab with an expired session receives it
548
+ // without reloading
549
+ let tokenObserver!: {next: (token: string | null) => void}
550
+ const mockSubscribe = vi.fn((observer: {next: (token: string | null) => void}) => {
551
+ tokenObserver = observer
552
+ return {unsubscribe: vi.fn()}
553
+ })
554
+ const mockTokenSource = {subscribe: mockSubscribe}
555
+
556
+ instance = createSanityInstance({
557
+ projectId: 'studio-project',
558
+ dataset: 'production',
559
+ studio: {
560
+ auth: {token: mockTokenSource},
561
+ },
562
+ })
563
+
564
+ getAuthState(instance)
565
+ tokenObserver.next('expired-session-token')
566
+ expect(getTokenState(instance).getCurrent()).toBe('expired-session-token')
567
+
568
+ tokenObserver.next('renewed-session-token')
569
+
570
+ expect(getAuthState(instance).getCurrent()).toMatchObject({
571
+ type: AuthStateType.LOGGED_IN,
572
+ token: 'renewed-session-token',
573
+ })
574
+ expect(getTokenState(instance).getCurrent()).toBe('renewed-session-token')
575
+ })
576
+
545
577
  it('falls back to default auth (storage token) when studio mode is disabled', () => {
546
578
  const storageToken = 'regular-storage-token'
547
579
  vi.mocked(getTokenFromStorage).mockReturnValue(storageToken)
@@ -35,3 +35,14 @@ export const OUT_OF_SYNC_RETRY_COUNT = 5
35
35
  */
36
36
  export const ACL_RETRY_BASE_DELAY = 500
37
37
  export const ACL_RETRY_MAX_DELAY = 10_000
38
+
39
+ /**
40
+ * Base delay (ms) before resubmitting an outgoing transaction whose submission
41
+ * failed with a retryable error (a network error, a 5xx, 408 or 429 response,
42
+ * a 401 from an expired session, or a 409 `transactionConflictError` from
43
+ * contention while committing). Backoff doubles on each successive
44
+ * attempt, capped at {@link SUBMISSION_RETRY_MAX_DELAY}. A change of client
45
+ * (new credentials) triggers the next attempt immediately.
46
+ */
47
+ export const SUBMISSION_RETRY_BASE_DELAY = 1000
48
+ export const SUBMISSION_RETRY_MAX_DELAY = 10_000
@@ -15,6 +15,7 @@ import {
15
15
  type UnfilteredResponseQueryOptions,
16
16
  type WelcomeEvent,
17
17
  } from '@sanity/client'
18
+ import {makePatches, stringifyPatches} from '@sanity/diff-match-patch'
18
19
  import {diffValue} from '@sanity/diff-patch'
19
20
  import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
20
21
  import {type Mutation, type SanityDocument} from '@sanity/types'
@@ -60,6 +61,7 @@ import {
60
61
  } from './documentStore'
61
62
  import {
62
63
  type ActionErrorEvent,
64
+ type DocumentEvent,
63
65
  type DocumentRemotePatchesEvent,
64
66
  type TransactionRevertedEvent,
65
67
  } from './events'
@@ -820,7 +822,13 @@ it('reverts failed outgoing transaction locally', async () => {
820
822
  const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
821
823
  vi.mocked(client.action).mockImplementation(async (...args) => {
822
824
  const [, {transactionId} = {}] = args
823
- if (transactionId === 'force-revert') throw new Error('example error')
825
+ if (transactionId === 'force-revert') {
826
+ throw new ClientError({
827
+ statusCode: 400,
828
+ headers: {},
829
+ body: {error: 'Bad Request', message: 'example error'},
830
+ })
831
+ }
824
832
  return await clientActionMockImplementation(...args)
825
833
  })
826
834
 
@@ -868,7 +876,7 @@ it('reverts failed outgoing transaction locally', async () => {
868
876
 
869
877
  await expect(revertedEventPromise).resolves.toMatchObject({
870
878
  type: 'reverted',
871
- message: 'example error',
879
+ message: 'Bad Request - example error',
872
880
  outgoing: {transactionId: 'force-revert'},
873
881
  })
874
882
 
@@ -1428,6 +1436,9 @@ vi.mock('./sharedListener.ts', () => {
1428
1436
  }
1429
1437
  })
1430
1438
 
1439
+ // read through getters so a test can lengthen the submission backoff
1440
+ const submissionRetryDelay = vi.hoisted(() => ({ms: 5}))
1441
+
1431
1442
  vi.mock('./documentConstants.ts', async (importOriginal) => {
1432
1443
  const original = await importOriginal<typeof import('./documentConstants')>()
1433
1444
  return {
@@ -1439,6 +1450,12 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
1439
1450
  OUT_OF_SYNC_RETRY_COUNT: 2,
1440
1451
  ACL_RETRY_BASE_DELAY: 0,
1441
1452
  ACL_RETRY_MAX_DELAY: 0,
1453
+ get SUBMISSION_RETRY_BASE_DELAY() {
1454
+ return submissionRetryDelay.ms
1455
+ },
1456
+ get SUBMISSION_RETRY_MAX_DELAY() {
1457
+ return submissionRetryDelay.ms
1458
+ },
1442
1459
  }
1443
1460
  })
1444
1461
 
@@ -2033,3 +2050,277 @@ it('keeps its retry budget per failure, not per listener', async () => {
2033
2050
  expect(() => documentState.getCurrent()).not.toThrow()
2034
2051
  unsubscribe()
2035
2052
  })
2053
+
2054
+ it('keeps edits made while the session is expired and submits them after re-authentication', async () => {
2055
+ // Same status and body the actions API returned on an expired SAML session
2056
+ const sessionExpired = new ClientError({
2057
+ statusCode: 401,
2058
+ headers: {},
2059
+ body: {statusCode: 401, error: 'Unauthorized', message: 'Session not found'},
2060
+ })
2061
+ let sessionValid = true
2062
+ let rejectedAttempts = 0
2063
+ const actualAction = vi.mocked(client.action).getMockImplementation()!
2064
+ vi.mocked(client.action).mockImplementation(async (...args) => {
2065
+ if (!sessionValid) {
2066
+ rejectedAttempts++
2067
+ throw sessionExpired
2068
+ }
2069
+ return await actualAction(...args)
2070
+ })
2071
+
2072
+ const events: DocumentEvent[] = []
2073
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
2074
+ resource,
2075
+ eventHandler: (e) => events.push(e),
2076
+ })
2077
+
2078
+ const doc = createDocumentHandle({
2079
+ documentId: DocumentId('session-expiry'),
2080
+ documentType: 'article',
2081
+ })
2082
+ const state = getDocumentState<TestDocument>(instance, doc)
2083
+ const syncStatus = getDocumentSyncStatus(instance, doc)
2084
+ const unsubscribe = state.subscribe()
2085
+ const unsubscribeSync = syncStatus.subscribe()
2086
+
2087
+ await applyDocumentActions(instance, {
2088
+ actions: [createDocument(doc), editDocument(doc, {set: {title: 'saved'}})],
2089
+ resource,
2090
+ }).then((r) => r.submitted())
2091
+
2092
+ // the session expires while the user keeps typing, one batch per flush
2093
+ sessionValid = false
2094
+ const results = ['saved, one', 'saved, one two', 'saved, one two three'].map((title) =>
2095
+ applyDocumentActions(instance, {
2096
+ actions: [editDocument(doc, {set: {title}})],
2097
+ resource,
2098
+ disableBatching: true,
2099
+ }),
2100
+ )
2101
+ await expect.poll(() => rejectedAttempts).toBeGreaterThanOrEqual(3)
2102
+
2103
+ // nothing is reverted: the edits stay on screen and the document reports
2104
+ // that it is not in sync
2105
+ expect(events.filter((e) => e.type === 'reverted')).toEqual([])
2106
+ expect(events.filter((e) => e.type === 'submission-failed').slice(0, 3)).toMatchObject([
2107
+ {message: 'Unauthorized - Session not found', attempt: 1},
2108
+ {message: 'Unauthorized - Session not found', attempt: 2},
2109
+ {message: 'Unauthorized - Session not found', attempt: 3},
2110
+ ])
2111
+ expect(state.getCurrent()?.title).toBe('saved, one two three')
2112
+ expect(syncStatus.getCurrent()).toBe(false)
2113
+
2114
+ // the user re-authenticates and every edit lands
2115
+ sessionValid = true
2116
+ await Promise.all(results.map((r) => r.then((x) => x.submitted())))
2117
+ expect(state.getCurrent()?.title).toBe('saved, one two three')
2118
+ expect(syncStatus.getCurrent()).toBe(true)
2119
+ expect(events.filter((e) => e.type === 'reverted')).toEqual([])
2120
+
2121
+ unsubscribeSync()
2122
+ unsubscribe()
2123
+ unsubscribeEvents()
2124
+ vi.mocked(client.action).mockImplementation(actualAction)
2125
+ })
2126
+
2127
+ it('retries a submission that failed with a server error or a dropped connection', async () => {
2128
+ const failures: unknown[] = [
2129
+ new ServerError({statusCode: 503, headers: {}, body: {}}),
2130
+ new Error('Request error while attempting to reach https://p.api.sanity.io'),
2131
+ ]
2132
+ const actualAction = vi.mocked(client.action).getMockImplementation()!
2133
+ vi.mocked(client.action).mockImplementation(async (...args) => {
2134
+ const [, {transactionId} = {}] = args
2135
+ if (transactionId === 'flaky' && failures.length) throw failures.shift()
2136
+ return await actualAction(...args)
2137
+ })
2138
+
2139
+ const doc = createDocumentHandle({
2140
+ documentId: DocumentId('flaky-network'),
2141
+ documentType: 'article',
2142
+ })
2143
+ const state = getDocumentState<TestDocument>(instance, doc)
2144
+ const unsubscribe = state.subscribe()
2145
+
2146
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], resource}).then((r) =>
2147
+ r.submitted(),
2148
+ )
2149
+ await applyDocumentActions(instance, {
2150
+ actions: [editDocument(doc, {set: {title: 'kept'}})],
2151
+ transactionId: 'flaky',
2152
+ resource,
2153
+ }).then((r) => r.submitted())
2154
+
2155
+ expect(failures).toEqual([])
2156
+ expect(state.getCurrent()?.title).toBe('kept')
2157
+
2158
+ unsubscribe()
2159
+ vi.mocked(client.action).mockImplementation(actualAction)
2160
+ })
2161
+
2162
+ it('treats transactionAlreadyExistsError on a retry as an ack of the earlier attempt', async () => {
2163
+ // the first attempt is committed but its response is lost; the retry then
2164
+ // collides with the transaction ID the server already recorded
2165
+ const actualAction = vi.mocked(client.action).getMockImplementation()!
2166
+ let attempts = 0
2167
+ vi.mocked(client.action).mockImplementation(async (...args) => {
2168
+ const [, {transactionId} = {}] = args
2169
+ if (transactionId !== 'lost-response') return await actualAction(...args)
2170
+ attempts++
2171
+ if (attempts === 1) {
2172
+ await actualAction(...args)
2173
+ throw new Error('Request error while attempting to reach https://p.api.sanity.io')
2174
+ }
2175
+ throw new ClientError({
2176
+ statusCode: 409,
2177
+ headers: {},
2178
+ body: {
2179
+ error: {
2180
+ type: 'transactionAlreadyExistsError',
2181
+ description: 'The transaction ID "lost-response" already exists',
2182
+ },
2183
+ },
2184
+ })
2185
+ })
2186
+
2187
+ const reverted: TransactionRevertedEvent[] = []
2188
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
2189
+ resource,
2190
+ eventHandler: (e) => {
2191
+ if (e.type === 'reverted') reverted.push(e)
2192
+ },
2193
+ })
2194
+
2195
+ const doc = createDocumentHandle({
2196
+ documentId: DocumentId('lost-response'),
2197
+ documentType: 'article',
2198
+ })
2199
+ const state = getDocumentState<TestDocument>(instance, doc)
2200
+ const unsubscribe = state.subscribe()
2201
+
2202
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], resource}).then((r) =>
2203
+ r.submitted(),
2204
+ )
2205
+ await applyDocumentActions(instance, {
2206
+ actions: [editDocument(doc, {set: {title: 'committed once'}})],
2207
+ transactionId: 'lost-response',
2208
+ resource,
2209
+ }).then((r) => r.submitted())
2210
+
2211
+ expect(attempts).toBe(2)
2212
+ expect(reverted).toEqual([])
2213
+ expect(state.getCurrent()?.title).toBe('committed once')
2214
+
2215
+ unsubscribe()
2216
+ unsubscribeEvents()
2217
+ vi.mocked(client.action).mockImplementation(actualAction)
2218
+ })
2219
+
2220
+ it('resubmits held text edits as soon as the credentials change, in order and once each', async () => {
2221
+ // The sequence from a writer whose session expired mid-edit: a text field
2222
+ // written with operation-preserving diffMatchPatch edits (what
2223
+ // @portabletext/plugin-sdk-value sends), every write on the old token
2224
+ // rejected with a 401, then a new token from logging in again in another tab
2225
+ const client$ = (getClientState as () => StateSource<SanityClient>)()
2226
+ .observable as ReplaySubject<SanityClient>
2227
+ const actualAction = vi.mocked(client.action).getMockImplementation()!
2228
+ let sessionValid = true
2229
+ let rejectedAttempts = 0
2230
+ vi.mocked(client.action).mockImplementation(async (...args) => {
2231
+ if (sessionValid) return await actualAction(...args)
2232
+ rejectedAttempts++
2233
+ throw new ClientError({
2234
+ statusCode: 401,
2235
+ headers: {},
2236
+ body: {statusCode: 401, error: 'Unauthorized', message: 'Session not found'},
2237
+ })
2238
+ })
2239
+ // the client store builds a new client when the token changes; it carries
2240
+ // the renewed token, so the server accepts its writes
2241
+ const renewedClient = {
2242
+ ...client,
2243
+ observable: {
2244
+ ...client.observable,
2245
+ action: (...args: Parameters<typeof actualAction>) => from(actualAction(...args)),
2246
+ },
2247
+ } as SanityClient
2248
+
2249
+ const reverted: TransactionRevertedEvent[] = []
2250
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
2251
+ resource,
2252
+ eventHandler: (e) => {
2253
+ if (e.type === 'reverted') reverted.push(e)
2254
+ },
2255
+ })
2256
+
2257
+ const doc = createDocumentHandle({
2258
+ documentId: DocumentId('renewed-session'),
2259
+ documentType: 'article',
2260
+ })
2261
+ const state = getDocumentState<TestDocument>(instance, doc)
2262
+ const unsubscribe = state.subscribe()
2263
+
2264
+ const typeText = (before: string, after: string) =>
2265
+ applyDocumentActions(instance, {
2266
+ actions: [
2267
+ {
2268
+ ...editDocument(doc, {
2269
+ diffMatchPatch: {title: stringifyPatches(makePatches(before, after))},
2270
+ }),
2271
+ preserveOperations: true,
2272
+ },
2273
+ ],
2274
+ resource,
2275
+ disableBatching: true,
2276
+ })
2277
+
2278
+ submissionRetryDelay.ms = 60_000
2279
+ try {
2280
+ await applyDocumentActions(instance, {
2281
+ actions: [createDocument(doc), editDocument(doc, {set: {title: 'Start:'}})],
2282
+ resource,
2283
+ }).then((r) => r.submitted())
2284
+
2285
+ sessionValid = false
2286
+ const results = [
2287
+ typeText('Start:', 'Start: the'),
2288
+ typeText('Start: the', 'Start: the mayor'),
2289
+ typeText('Start: the mayor', 'Start: the mayor said'),
2290
+ ]
2291
+
2292
+ // the first write is rejected and the 60s backoff holds its retry, while
2293
+ // every edit stays applied locally
2294
+ await expect.poll(() => rejectedAttempts).toBe(1)
2295
+ await new Promise((resolve) => setTimeout(resolve, 100))
2296
+ expect(rejectedAttempts).toBe(1)
2297
+ expect(state.getCurrent()?.title).toBe('Start: the mayor said')
2298
+
2299
+ // logging in again: the held write and the queued ones land long before
2300
+ // the backoff would have elapsed
2301
+ client$.next(renewedClient)
2302
+ await Promise.race([
2303
+ Promise.all(results.map((r) => r.then((x) => x.submitted()))),
2304
+ new Promise((_, reject) =>
2305
+ setTimeout(() => reject(new Error('edits were not resubmitted on the new client')), 2000),
2306
+ ),
2307
+ ])
2308
+
2309
+ expect(rejectedAttempts).toBe(1)
2310
+ expect(reverted).toEqual([])
2311
+ expect(state.getCurrent()?.title).toBe('Start: the mayor said')
2312
+ // the server copy has each edit applied exactly once
2313
+ await expect(
2314
+ client.fetch<string>(
2315
+ '*[_id == $id][0].title',
2316
+ {id: getDraftId(DocumentId('renewed-session'))},
2317
+ {filterResponse: true},
2318
+ ),
2319
+ ).resolves.toBe('Start: the mayor said')
2320
+ } finally {
2321
+ submissionRetryDelay.ms = 5
2322
+ unsubscribe()
2323
+ unsubscribeEvents()
2324
+ vi.mocked(client.action).mockImplementation(actualAction)
2325
+ }
2326
+ })
@@ -1,10 +1,18 @@
1
- import {type Action, ClientError, CorsOriginError, type Mutation, ServerError} from '@sanity/client'
1
+ import {
2
+ type Action,
3
+ ClientError,
4
+ CorsOriginError,
5
+ type Mutation,
6
+ type SanityClient,
7
+ ServerError,
8
+ } from '@sanity/client'
2
9
  import {DocumentId, getDraftId} from '@sanity/id-utils'
3
10
  import {jsonMatch} from '@sanity/json-match'
4
11
  import {type ExprNode} from 'groq-js'
5
12
  import {
6
13
  catchError,
7
14
  concatMap,
15
+ defer,
8
16
  distinctUntilChanged,
9
17
  EMPTY,
10
18
  filter,
@@ -16,6 +24,7 @@ import {
16
24
  Observable,
17
25
  of,
18
26
  pairwise,
27
+ race,
19
28
  retry,
20
29
  startWith,
21
30
  Subject,
@@ -54,6 +63,8 @@ import {
54
63
  OUT_OF_SYNC_RETRY_BASE_DELAY,
55
64
  OUT_OF_SYNC_RETRY_COUNT,
56
65
  OUT_OF_SYNC_RETRY_MAX_DELAY,
66
+ SUBMISSION_RETRY_BASE_DELAY,
67
+ SUBMISSION_RETRY_MAX_DELAY,
57
68
  } from './documentConstants'
58
69
  import {
59
70
  type DocumentEvent,
@@ -86,6 +97,7 @@ import {
86
97
  type UnverifiedDocumentRevision,
87
98
  } from './reducers'
88
99
  import {createFetchDocument, createSharedListener, type SharedListener} from './sharedListener'
100
+ import {classifySubmissionError} from './submissionErrors'
89
101
 
90
102
  export interface DocumentStoreState {
91
103
  documentStates: {[TDocumentId in string]?: DocumentState}
@@ -415,12 +427,39 @@ const subscribeToQueuedAndApplyNextTransaction = ({
415
427
  .subscribe({error: (error) => state.set('setError', {error})})
416
428
  }
417
429
 
430
+ const getErrorMessage = (error: unknown) =>
431
+ error instanceof Error ? error.message : 'Request failed'
432
+
433
+ const submitOutgoingTransaction = (client: SanityClient, outgoing: OutgoingTransaction) => {
434
+ // liveEdit transactions route to the mutations API; everything else routes
435
+ // to the actions API. processActions rejects transactions that mix the two,
436
+ // and reducers won't batch across that boundary, so a batch is always
437
+ // entirely liveEdit or entirely not.
438
+ if (outgoing.actions.some((action) => !isReleaseAction(action) && action.liveEdit)) {
439
+ return client.observable.mutate(outgoing.outgoingMutations as Mutation[], {
440
+ transactionId: outgoing.transactionId,
441
+ visibility: 'async',
442
+ returnDocuments: false,
443
+ returnFirst: false,
444
+ tag: 'document.mutate',
445
+ skipCrossDatasetReferenceValidation: true,
446
+ })
447
+ }
448
+
449
+ return client.observable.action(outgoing.outgoingActions as Action[], {
450
+ transactionId: outgoing.transactionId,
451
+ skipCrossDatasetReferenceValidation: true,
452
+ tag: 'document.action',
453
+ })
454
+ }
455
+
418
456
  const subscribeToAppliedAndSubmitNextTransaction = ({
419
457
  state,
420
458
  instance,
421
459
  key: {resource},
422
460
  }: StoreContext<DocumentStoreState, BoundResourceKey>) => {
423
461
  const {events} = state.get()
462
+ const clients$ = getClientState(instance, {apiVersion: API_VERSION, resource}).observable
424
463
 
425
464
  return state.observable
426
465
  .pipe(
@@ -439,51 +478,59 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
439
478
  tap((next) => state.set('transitionAppliedTransactionsToOutgoing', next)),
440
479
  map((s) => s.outgoing),
441
480
  distinctUntilChanged(),
442
- withLatestFrom(
443
- getClientState(instance, {
444
- apiVersion: API_VERSION,
445
- resource,
446
- }).observable,
447
- ),
448
- concatMap(([outgoing, client]) => {
481
+ withLatestFrom(clients$),
482
+ concatMap(([outgoing, initialClient]) => {
449
483
  if (!outgoing) return EMPTY
450
484
 
451
- const revertOnError = catchError((error: unknown) => {
452
- state.set('revertOutgoingTransaction', revertOutgoingTransaction)
453
- const message = error instanceof Error ? error.message : 'Request failed'
454
- events.next({type: 'reverted', message, outgoing, error})
455
- return EMPTY
456
- })
457
-
458
- const toResult = map((result: unknown) => ({
459
- result: result as DocumentTransactionSubmissionResult,
460
- outgoing,
461
- }))
462
-
463
- // liveEdit transactions route to the mutations API; everything else routes
464
- // to the actions API. processActions rejects transactions that mix the two,
465
- // and reducers won't batch across that boundary, so a batch is always
466
- // entirely liveEdit or entirely not.
467
- if (outgoing.actions.some((action) => !isReleaseAction(action) && action.liveEdit)) {
468
- return client.observable
469
- .mutate(outgoing.outgoingMutations as Mutation[], {
470
- transactionId: outgoing.transactionId,
471
- visibility: 'async',
472
- returnDocuments: false,
473
- returnFirst: false,
474
- tag: 'document.mutate',
475
- skipCrossDatasetReferenceValidation: true,
476
- })
477
- .pipe(revertOnError, toResult)
478
- }
485
+ // the client that made the latest attempt. the client store rebuilds
486
+ // its clients when the token changes, so a different client means
487
+ // different credentials and a retry after a 401 can go right away
488
+ let attemptClient: SanityClient | undefined
479
489
 
480
- return client.observable
481
- .action(outgoing.outgoingActions as Action[], {
482
- transactionId: outgoing.transactionId,
483
- skipCrossDatasetReferenceValidation: true,
484
- tag: 'document.action',
485
- })
486
- .pipe(revertOnError, toResult)
490
+ return defer(() => (attemptClient ? clients$.pipe(first()) : of(initialClient))).pipe(
491
+ concatMap((client) => {
492
+ attemptClient = client
493
+ return submitOutgoingTransaction(client, outgoing)
494
+ }),
495
+ retry({
496
+ delay: (error: unknown, retryCount) => {
497
+ if (classifySubmissionError(error) !== 'retry') return throwError(() => error)
498
+
499
+ // keep the transaction and its optimistic local state. the
500
+ // document stays out of sync (see `getDocumentSyncStatus`) and
501
+ // later edits queue behind this transaction until it lands
502
+ events.next({
503
+ type: 'submission-failed',
504
+ message: getErrorMessage(error),
505
+ error,
506
+ outgoing,
507
+ attempt: retryCount,
508
+ })
509
+ const backoff = Math.min(
510
+ SUBMISSION_RETRY_BASE_DELAY * 2 ** (retryCount - 1),
511
+ SUBMISSION_RETRY_MAX_DELAY,
512
+ )
513
+ return race(
514
+ timer(backoff),
515
+ clients$.pipe(first((client) => client !== attemptClient)),
516
+ )
517
+ },
518
+ }),
519
+ catchError((error: unknown) => {
520
+ if (classifySubmissionError(error) === 'accepted') {
521
+ // an earlier attempt was committed and only its response was
522
+ // lost, so this is an ack
523
+ return of({transactionId: outgoing.transactionId, documentIds: [], results: []})
524
+ }
525
+ state.set('revertOutgoingTransaction', revertOutgoingTransaction)
526
+ events.next({type: 'reverted', message: getErrorMessage(error), outgoing, error})
527
+ return EMPTY
528
+ }),
529
+ map((result: unknown) => ({
530
+ result: result as DocumentTransactionSubmissionResult,
531
+ outgoing,
532
+ })),
533
+ )
487
534
  }),
488
535
  tap(({outgoing, result}) => {
489
536
  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.
@@ -188,12 +205,10 @@ export function getDocumentEvents(outgoing: OutgoingTransaction): DocumentEvent[
188
205
  )
189
206
 
190
207
  return Object.entries(documentIdsByAction).flatMap(([actionType, documentIds]) =>
191
- Array.from(documentIds ?? []).map(
192
- (documentId): DocumentEvent => ({
193
- type: actionMap[actionType as MappedActionType],
194
- documentId,
195
- outgoing,
196
- }),
197
- ),
208
+ Array.from(documentIds ?? []).map((documentId): DocumentEvent => ({
209
+ type: actionMap[actionType as MappedActionType],
210
+ documentId,
211
+ outgoing,
212
+ })),
198
213
  )
199
214
  }