@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.
- 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 +137 -45
- package/dist/index.js.map +1 -1
- package/dist/{telemetryManager-w-AMXtmz.js → telemetryManager-BKcXNRX_.js} +2 -2
- package/dist/{telemetryManager-w-AMXtmz.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-ByqNiWrK.js → version-BAVcaHtM.js} +2 -2
- package/dist/{version-ByqNiWrK.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 +20 -0
- package/src/document/documentStore.test.ts +363 -2
- package/src/document/documentStore.ts +89 -43
- package/src/document/events.ts +17 -0
- package/src/document/reducers.test.ts +224 -0
- package/src/document/reducers.ts +108 -25
- 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'
|
|
@@ -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')
|
|
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 {
|
|
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.
|