@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.
- package/dist/_exports/_internal.d.ts +1 -1
- package/dist/_exports/_internal.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +74 -29
- package/dist/index.js.map +1 -1
- package/dist/{telemetryManager-BmGqzj0b.js → telemetryManager-BKcXNRX_.js} +2 -2
- package/dist/{telemetryManager-BmGqzj0b.js.map → telemetryManager-BKcXNRX_.js.map} +1 -1
- package/dist/{utils-BMwA8Kg9.d.ts → utils-E9GB-6Gs.d.ts} +19 -3
- package/dist/{utils-BMwA8Kg9.d.ts.map → utils-E9GB-6Gs.d.ts.map} +1 -1
- package/dist/{version-CPumgx0u.js → version-BAVcaHtM.js} +2 -2
- package/dist/{version-CPumgx0u.js.map → version-BAVcaHtM.js.map} +1 -1
- package/package.json +4 -4
- 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 +294 -2
- package/src/document/documentStore.ts +89 -43
- package/src/document/events.ts +17 -0
- package/src/document/submissionErrors.test.ts +63 -0
- package/src/document/submissionErrors.ts +51 -0
|
@@ -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')
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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)
|
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.
|
|
@@ -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
|
+
}
|