@sanity/sdk 3.6.0 → 3.7.0-next.20260929183120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_exports/_internal.d.ts +2 -2
- package/dist/_exports/_internal.js +1 -1
- package/dist/_exports/dashboard.d.ts +1 -1
- package/dist/{applications-BbObAGpD.d.ts → applications-Bq3Z9Piv.d.ts} +19 -3
- package/dist/{applications-BbObAGpD.d.ts.map → applications-Bq3Z9Piv.d.ts.map} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +74 -29
- package/dist/index.js.map +1 -1
- package/dist/{topicStore-fZDBrwRP.d.ts → topicStore-CkO7ocKC.d.ts} +2 -2
- package/dist/{topicStore-fZDBrwRP.d.ts.map → topicStore-CkO7ocKC.d.ts.map} +1 -1
- package/dist/{version-7QYPY1nU.js → version-uxK_Oa9x.js} +2 -2
- package/dist/{version-7QYPY1nU.js.map → version-uxK_Oa9x.js.map} +1 -1
- package/package.json +1 -1
- package/src/_exports/index.ts +1 -0
- package/src/auth/authStore.test.ts +32 -0
- package/src/document/documentConstants.ts +11 -0
- package/src/document/documentStore.test.ts +293 -2
- package/src/document/documentStore.ts +90 -43
- package/src/document/events.ts +22 -7
- package/src/document/submissionErrors.test.ts +63 -0
- package/src/document/submissionErrors.ts +51 -0
package/package.json
CHANGED
package/src/_exports/index.ts
CHANGED
|
@@ -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')
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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)
|
package/src/document/events.ts
CHANGED
|
@@ -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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}),
|
|
197
|
-
),
|
|
208
|
+
Array.from(documentIds ?? []).map((documentId): DocumentEvent => ({
|
|
209
|
+
type: actionMap[actionType as MappedActionType],
|
|
210
|
+
documentId,
|
|
211
|
+
outgoing,
|
|
212
|
+
})),
|
|
198
213
|
)
|
|
199
214
|
}
|