@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
|
@@ -4,6 +4,7 @@ import {parse} from 'groq-js'
|
|
|
4
4
|
import {Subject} from 'rxjs'
|
|
5
5
|
import {describe, expect, it} from 'vitest'
|
|
6
6
|
|
|
7
|
+
import {UNVERIFIED_REVISION_RETENTION_TIME} from './documentConstants'
|
|
7
8
|
import {type DocumentEvent} from './events'
|
|
8
9
|
import {type RemoteDocument} from './listen'
|
|
9
10
|
import {type DocumentSet} from './processMutations'
|
|
@@ -556,6 +557,118 @@ describe('cleanupOutgoingTransaction', () => {
|
|
|
556
557
|
})
|
|
557
558
|
})
|
|
558
559
|
|
|
560
|
+
describe('document state retention between ack and echo', () => {
|
|
561
|
+
const docId = 'doc1'
|
|
562
|
+
const justNow = new Date().toISOString()
|
|
563
|
+
const longAgo = new Date(Date.now() - UNVERIFIED_REVISION_RETENTION_TIME - 1000).toISOString()
|
|
564
|
+
|
|
565
|
+
const outgoing: NonNullable<SyncTransactionState['outgoing']> = {
|
|
566
|
+
transactionId: 'txnOut',
|
|
567
|
+
actions: [
|
|
568
|
+
{
|
|
569
|
+
type: 'document.edit',
|
|
570
|
+
documentId: docId,
|
|
571
|
+
documentType: 'book',
|
|
572
|
+
liveEdit: true,
|
|
573
|
+
patches: [{set: {foo: 'changed'}}],
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
disableBatching: false,
|
|
577
|
+
batchedTransactionIds: ['txnOut'],
|
|
578
|
+
outgoingActions: [],
|
|
579
|
+
outgoingMutations: [],
|
|
580
|
+
base: {},
|
|
581
|
+
working: {},
|
|
582
|
+
previous: {},
|
|
583
|
+
previousRevs: {},
|
|
584
|
+
timestamp: justNow,
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const unverifiedRevision = (timestamp: string) => ({
|
|
588
|
+
txnOut: {transactionId: 'txnOut', documentId: docId, previousRev: 'txn0', timestamp},
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
const stateAfterAck = (timestamp: string): SyncTransactionState => ({
|
|
592
|
+
queued: [],
|
|
593
|
+
applied: [],
|
|
594
|
+
outgoing,
|
|
595
|
+
grants,
|
|
596
|
+
documentStates: {
|
|
597
|
+
[docId]: {
|
|
598
|
+
id: docId,
|
|
599
|
+
// the UI unsubscribed before the ack arrived, so the transaction's
|
|
600
|
+
// own subscription id is the only one left
|
|
601
|
+
subscriptions: ['txnOut'],
|
|
602
|
+
local: {...exampleDoc, _id: docId, foo: 'changed'},
|
|
603
|
+
remote: {...exampleDoc, _id: docId, foo: 'old'},
|
|
604
|
+
unverifiedRevisions: unverifiedRevision(timestamp),
|
|
605
|
+
recentOwnTransactionIds: ['txnOut'],
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('keeps echo-recognition memory alive through cleanup so a late echo still labels "local"', () => {
|
|
611
|
+
// the HTTP ack arrives and cleans up the outgoing transaction's
|
|
612
|
+
// subscription. previously this deleted the whole DocumentState
|
|
613
|
+
// immediately, before the listener echo had a chance to arrive
|
|
614
|
+
const afterCleanup = cleanupOutgoingTransaction(stateAfterAck(justNow))
|
|
615
|
+
expect(afterCleanup.outgoing).toBeUndefined()
|
|
616
|
+
|
|
617
|
+
const docStateAfterCleanup = afterCleanup.documentStates[docId]
|
|
618
|
+
expect(docStateAfterCleanup).toBeDefined()
|
|
619
|
+
expect(docStateAfterCleanup?.subscriptions).toEqual([])
|
|
620
|
+
expect(docStateAfterCleanup?.unverifiedRevisions?.['txnOut']).toBeDefined()
|
|
621
|
+
|
|
622
|
+
// now the echo finally arrives over the shared listener
|
|
623
|
+
const events = new Subject<DocumentEvent>()
|
|
624
|
+
const received: DocumentEvent[] = []
|
|
625
|
+
events.subscribe((e) => received.push(e))
|
|
626
|
+
|
|
627
|
+
const echo: RemoteDocument = {
|
|
628
|
+
type: 'mutation',
|
|
629
|
+
documentId: docId,
|
|
630
|
+
document: {...exampleDoc, _id: docId, foo: 'changed'},
|
|
631
|
+
revision: 'txnOut',
|
|
632
|
+
previousRev: 'txn0',
|
|
633
|
+
timestamp: new Date().toISOString(),
|
|
634
|
+
mutations: [{patch: {id: docId, set: {foo: 'changed'}}}],
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const afterEcho = applyRemoteDocument(afterCleanup, echo, events)
|
|
638
|
+
|
|
639
|
+
const remotePatchesEvents = received.filter((e) => e.type === 'remote-patches')
|
|
640
|
+
expect(remotePatchesEvents).toHaveLength(1)
|
|
641
|
+
expect(remotePatchesEvents[0]).toMatchObject({origin: 'local', transactionId: 'txnOut'})
|
|
642
|
+
|
|
643
|
+
// the echo verified the revision and nothing was subscribed any more, so
|
|
644
|
+
// the now fully-idle document state can finally be evicted
|
|
645
|
+
expect(afterEcho.documentStates[docId]).toBeUndefined()
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('stops waiting for an echo that is older than the retention time', () => {
|
|
649
|
+
// an accepted transaction whose operations all no-op server-side never
|
|
650
|
+
// produces an echo, so the wait has to expire or the document (and its
|
|
651
|
+
// listener) would be pinned for the lifetime of the store
|
|
652
|
+
const afterCleanup = cleanupOutgoingTransaction(stateAfterAck(longAgo))
|
|
653
|
+
expect(afterCleanup.documentStates[docId]).toBeUndefined()
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('does not extend the wait on `recentOwnTransactionIds` alone', () => {
|
|
657
|
+
const state = stateAfterAck(justNow)
|
|
658
|
+
const documentState = state.documentStates[docId]!
|
|
659
|
+
const withoutUnverifiedRevisions: SyncTransactionState = {
|
|
660
|
+
...state,
|
|
661
|
+
documentStates: {
|
|
662
|
+
[docId]: {...documentState, unverifiedRevisions: {}, recentOwnTransactionIds: ['txnOut']},
|
|
663
|
+
},
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
expect(
|
|
667
|
+
cleanupOutgoingTransaction(withoutUnverifiedRevisions).documentStates[docId],
|
|
668
|
+
).toBeUndefined()
|
|
669
|
+
})
|
|
670
|
+
})
|
|
671
|
+
|
|
559
672
|
describe('revertOutgoingTransaction', () => {
|
|
560
673
|
it('reverts the outgoing transaction and updates documentStates by removing unverified revisions', () => {
|
|
561
674
|
const draftId = getDraftId(DocumentId('doc1'))
|
|
@@ -686,6 +799,117 @@ describe('revertOutgoingTransaction', () => {
|
|
|
686
799
|
// transaction id is gone from its subscriptions
|
|
687
800
|
expect(newState.documentStates[pubId]?.subscriptions).toEqual(['sub-ui'])
|
|
688
801
|
})
|
|
802
|
+
|
|
803
|
+
it('evicts a document left with no subscribers once its unverified revision is discarded', () => {
|
|
804
|
+
const docId = 'doc1'
|
|
805
|
+
const state: SyncTransactionState = {
|
|
806
|
+
queued: [],
|
|
807
|
+
applied: [],
|
|
808
|
+
outgoing: {
|
|
809
|
+
transactionId: 'txnOut',
|
|
810
|
+
actions: [
|
|
811
|
+
{
|
|
812
|
+
type: 'document.edit',
|
|
813
|
+
documentId: docId,
|
|
814
|
+
documentType: 'book',
|
|
815
|
+
liveEdit: true,
|
|
816
|
+
patches: [{set: {foo: 'changed'}}],
|
|
817
|
+
},
|
|
818
|
+
],
|
|
819
|
+
disableBatching: false,
|
|
820
|
+
batchedTransactionIds: ['txnOut'],
|
|
821
|
+
outgoingActions: [],
|
|
822
|
+
outgoingMutations: [],
|
|
823
|
+
base: {},
|
|
824
|
+
working: {},
|
|
825
|
+
previous: {},
|
|
826
|
+
previousRevs: {},
|
|
827
|
+
timestamp: new Date().toISOString(),
|
|
828
|
+
},
|
|
829
|
+
grants,
|
|
830
|
+
documentStates: {
|
|
831
|
+
// the reverted transaction was this document's last subscriber, so the
|
|
832
|
+
// revision discarded below is the only thing keeping it around
|
|
833
|
+
[docId]: {
|
|
834
|
+
id: docId,
|
|
835
|
+
subscriptions: [],
|
|
836
|
+
local: {...exampleDoc, _id: docId, foo: 'changed'},
|
|
837
|
+
remote: {...exampleDoc, _id: docId, foo: 'old'},
|
|
838
|
+
unverifiedRevisions: {
|
|
839
|
+
txnOut: {
|
|
840
|
+
transactionId: 'txnOut',
|
|
841
|
+
documentId: docId,
|
|
842
|
+
previousRev: 'txn0',
|
|
843
|
+
timestamp: new Date().toISOString(),
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
expect(revertOutgoingTransaction(state).documentStates[docId]).toBeUndefined()
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
it('leaves `local` intact for a document still waiting on its own acked transaction', () => {
|
|
854
|
+
const revertedId = 'doc-reverted'
|
|
855
|
+
const retainedId = 'doc-retained'
|
|
856
|
+
const state: SyncTransactionState = {
|
|
857
|
+
queued: [],
|
|
858
|
+
applied: [],
|
|
859
|
+
outgoing: {
|
|
860
|
+
transactionId: 'txnOut',
|
|
861
|
+
actions: [
|
|
862
|
+
{
|
|
863
|
+
type: 'document.edit',
|
|
864
|
+
documentId: revertedId,
|
|
865
|
+
documentType: 'book',
|
|
866
|
+
liveEdit: true,
|
|
867
|
+
patches: [{set: {foo: 'changed'}}],
|
|
868
|
+
},
|
|
869
|
+
],
|
|
870
|
+
disableBatching: false,
|
|
871
|
+
batchedTransactionIds: ['txnOut'],
|
|
872
|
+
outgoingActions: [],
|
|
873
|
+
outgoingMutations: [],
|
|
874
|
+
base: {},
|
|
875
|
+
working: {},
|
|
876
|
+
previous: {},
|
|
877
|
+
previousRevs: {},
|
|
878
|
+
timestamp: new Date().toISOString(),
|
|
879
|
+
},
|
|
880
|
+
grants,
|
|
881
|
+
documentStates: {
|
|
882
|
+
[revertedId]: {
|
|
883
|
+
id: revertedId,
|
|
884
|
+
subscriptions: ['txnOut'],
|
|
885
|
+
local: {...exampleDoc, _id: revertedId, foo: 'changed'},
|
|
886
|
+
remote: {...exampleDoc, _id: revertedId, foo: 'old'},
|
|
887
|
+
},
|
|
888
|
+
// unrelated to the reverted batch: acked, unsubscribed, and still
|
|
889
|
+
// waiting on the echo that will settle its optimistic value
|
|
890
|
+
[retainedId]: {
|
|
891
|
+
id: retainedId,
|
|
892
|
+
subscriptions: [],
|
|
893
|
+
local: {...exampleDoc, _id: retainedId, foo: 'acked'},
|
|
894
|
+
remote: {...exampleDoc, _id: retainedId, foo: 'old'},
|
|
895
|
+
unverifiedRevisions: {
|
|
896
|
+
txnAcked: {
|
|
897
|
+
transactionId: 'txnAcked',
|
|
898
|
+
documentId: retainedId,
|
|
899
|
+
previousRev: 'txn0',
|
|
900
|
+
timestamp: new Date().toISOString(),
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const newState = revertOutgoingTransaction(state)
|
|
908
|
+
// the reverted document's only subscriber was the batch itself, so
|
|
909
|
+
// unregistering it evicts the state
|
|
910
|
+
expect(newState.documentStates[revertedId]).toBeUndefined()
|
|
911
|
+
expect(newState.documentStates[retainedId]?.local).toMatchObject({foo: 'acked'})
|
|
912
|
+
})
|
|
689
913
|
})
|
|
690
914
|
|
|
691
915
|
describe('applyRemoteDocument', () => {
|
package/src/document/reducers.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {insecureRandomId} from '../utils/ids'
|
|
|
8
8
|
import {isDeepEqual, omitProperty} from '../utils/object'
|
|
9
9
|
import {setCleanupTimeout} from '../utils/setCleanupTimeout'
|
|
10
10
|
import {type Action} from './actions'
|
|
11
|
-
import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
|
|
11
|
+
import {DOCUMENT_STATE_CLEAR_DELAY, UNVERIFIED_REVISION_RETENTION_TIME} from './documentConstants'
|
|
12
12
|
import {type DocumentState, type DocumentStoreState} from './documentStore'
|
|
13
13
|
import {type RemoteDocument} from './listen'
|
|
14
14
|
import {ActionError, processActions} from './processActions/processActions'
|
|
@@ -401,7 +401,13 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
|
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
-
|
|
404
|
+
// every ack is also when a previously retained document's echo may have
|
|
405
|
+
// stopped being expected, so re-make that eviction decision here
|
|
406
|
+
return {
|
|
407
|
+
...next,
|
|
408
|
+
outgoing: undefined,
|
|
409
|
+
documentStates: evictOrphanedDocumentStates(next.documentStates),
|
|
410
|
+
}
|
|
405
411
|
}
|
|
406
412
|
|
|
407
413
|
export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTransactionState {
|
|
@@ -446,20 +452,38 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
|
|
|
446
452
|
...base,
|
|
447
453
|
applied: nextApplied,
|
|
448
454
|
outgoing: undefined,
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
455
|
+
// discarding the outgoing transaction drops its echo-recognition memory,
|
|
456
|
+
// which can leave a document that was only being kept alive for that echo
|
|
457
|
+
// with nothing left to wait for, so sweep afterwards
|
|
458
|
+
documentStates: evictOrphanedDocumentStates(
|
|
459
|
+
Object.fromEntries(
|
|
460
|
+
Object.entries(base.documentStates)
|
|
461
|
+
.filter((e): e is [string, DocumentState] => !!e[1])
|
|
462
|
+
.map(([documentId, documentState]) => {
|
|
463
|
+
const {unverifiedRevisions = {}} = documentState
|
|
464
|
+
const next: DocumentState = {
|
|
465
|
+
...documentState,
|
|
466
|
+
unverifiedRevisions:
|
|
467
|
+
prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
|
|
468
|
+
? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
|
|
469
|
+
: unverifiedRevisions,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// a document with no subscribers that is still waiting on the echo
|
|
473
|
+
// of a different, already acked transaction of its own is not
|
|
474
|
+
// represented in `working` (only `applied` transactions are
|
|
475
|
+
// re-applied above), so rebuilding its `local` from `remote` would
|
|
476
|
+
// drop that transaction's effect. leave it for the echo to resolve
|
|
477
|
+
if (!next.subscriptions.length && isAwaitingOwnEcho(next)) {
|
|
478
|
+
return [documentId, next] as const
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return [
|
|
482
|
+
documentId,
|
|
483
|
+
{...next, local: documentId in working ? working[documentId] : next.local},
|
|
484
|
+
] as const
|
|
485
|
+
}),
|
|
486
|
+
),
|
|
463
487
|
),
|
|
464
488
|
}
|
|
465
489
|
}
|
|
@@ -579,7 +603,7 @@ export function applyRemoteDocument(
|
|
|
579
603
|
|
|
580
604
|
return {
|
|
581
605
|
...prev,
|
|
582
|
-
documentStates: {
|
|
606
|
+
documentStates: evictOrphanedDocumentStates({
|
|
583
607
|
...prev.documentStates,
|
|
584
608
|
[documentId]: {
|
|
585
609
|
...prevDocState,
|
|
@@ -588,7 +612,7 @@ export function applyRemoteDocument(
|
|
|
588
612
|
local,
|
|
589
613
|
unverifiedRevisions,
|
|
590
614
|
},
|
|
591
|
-
},
|
|
615
|
+
}),
|
|
592
616
|
}
|
|
593
617
|
}
|
|
594
618
|
|
|
@@ -645,7 +669,7 @@ export function applyRemoteDocument(
|
|
|
645
669
|
return {
|
|
646
670
|
...prev,
|
|
647
671
|
applied: nextApplied,
|
|
648
|
-
documentStates: {
|
|
672
|
+
documentStates: evictOrphanedDocumentStates({
|
|
649
673
|
...prev.documentStates,
|
|
650
674
|
[documentId]: {
|
|
651
675
|
...prevDocState,
|
|
@@ -654,7 +678,7 @@ export function applyRemoteDocument(
|
|
|
654
678
|
local,
|
|
655
679
|
unverifiedRevisions,
|
|
656
680
|
},
|
|
657
|
-
},
|
|
681
|
+
}),
|
|
658
682
|
}
|
|
659
683
|
}
|
|
660
684
|
|
|
@@ -685,22 +709,81 @@ export function removeSubscriptionIdFromDocument(
|
|
|
685
709
|
subscriptionId: string,
|
|
686
710
|
): SyncTransactionState {
|
|
687
711
|
const prevDocState = prev.documentStates?.[documentId]
|
|
688
|
-
const prevSubscriptions = prevDocState?.subscriptions ?? []
|
|
689
|
-
const subscriptions = prevSubscriptions.filter((id) => id !== subscriptionId)
|
|
690
|
-
|
|
691
712
|
if (!prevDocState) return prev
|
|
692
|
-
|
|
713
|
+
|
|
714
|
+
const subscriptions = prevDocState.subscriptions.filter((id) => id !== subscriptionId)
|
|
715
|
+
|
|
716
|
+
// a document with no subscribers left is normally evicted below, but if a
|
|
717
|
+
// transaction was just submitted for it, its echo-recognition memory
|
|
718
|
+
// (`unverifiedRevisions`) is still waiting on the listener echo.
|
|
719
|
+
// `cleanupOutgoingTransaction` removes the outgoing transaction's own
|
|
720
|
+
// subscription right at HTTP ack, before the echo arrives, so deleting the
|
|
721
|
+
// state here would lose that memory and make the echo mislabel as
|
|
722
|
+
// `origin: 'remote'` (or, worse, get skipped outright). keep the state
|
|
723
|
+
// alive, unsubscribed, until the echo verifies or the wait expires
|
|
724
|
+
if (!subscriptions.length && !isAwaitingOwnEcho(prevDocState)) {
|
|
693
725
|
return {...prev, documentStates: omitProperty(prev.documentStates, documentId)}
|
|
694
726
|
}
|
|
727
|
+
|
|
695
728
|
return {
|
|
696
729
|
...prev,
|
|
697
730
|
documentStates: {
|
|
698
731
|
...prev.documentStates,
|
|
699
|
-
[documentId]: {...prevDocState, subscriptions
|
|
732
|
+
[documentId]: {...prevDocState, subscriptions},
|
|
700
733
|
},
|
|
701
734
|
}
|
|
702
735
|
}
|
|
703
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Whether this document still expects the listener echo of one of its own
|
|
739
|
+
* submitted transactions. That expectation is the only reason a document state
|
|
740
|
+
* with no subscribers is kept around.
|
|
741
|
+
*
|
|
742
|
+
* Revisions stop counting after `UNVERIFIED_REVISION_RETENTION_TIME` because an
|
|
743
|
+
* echo is not guaranteed to arrive at all: Content Lake records no transaction
|
|
744
|
+
* when every operation resolves to a no-op (e.g. a keyed path that no longer
|
|
745
|
+
* exists), while the client bumps `_rev` regardless and so tracks a revision
|
|
746
|
+
* for it. Without the expiry, such a document (and the listener keyed off its
|
|
747
|
+
* state) would be pinned for the lifetime of the store.
|
|
748
|
+
*
|
|
749
|
+
* `recentOwnTransactionIds` deliberately does not extend the wait: it is capped
|
|
750
|
+
* rather than drained, so honoring it would keep every edited document alive.
|
|
751
|
+
*/
|
|
752
|
+
function isAwaitingOwnEcho(documentState: DocumentState): boolean {
|
|
753
|
+
const expiresAfter = Date.now() - UNVERIFIED_REVISION_RETENTION_TIME
|
|
754
|
+
return Object.values(documentState.unverifiedRevisions ?? {}).some(
|
|
755
|
+
(revision) => !!revision && new Date(revision.timestamp).getTime() > expiresAfter,
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Removes every document state that has no subscribers left and no own
|
|
761
|
+
* transaction still awaiting its echo.
|
|
762
|
+
*
|
|
763
|
+
* This is a sweep rather than a decision made where the last subscriber leaves
|
|
764
|
+
* because `removeSubscriptionIdFromDocument` defers that eviction while an echo
|
|
765
|
+
* is in flight, and both halves of the condition can change without a
|
|
766
|
+
* subscriber going anywhere: the echo can verify, be dropped by a sync, be
|
|
767
|
+
* discarded by a revert, or simply never arrive. Every reducer that can settle
|
|
768
|
+
* an in-flight transaction sweeps, so the deferred decision is always re-made.
|
|
769
|
+
*
|
|
770
|
+
* Note this can evict the very document whose remote event is being processed,
|
|
771
|
+
* which synchronously unsubscribes that document's listener from inside its own
|
|
772
|
+
* emission (see `subscribeToSubscriptionsAndListenToDocuments`).
|
|
773
|
+
*/
|
|
774
|
+
function evictOrphanedDocumentStates(
|
|
775
|
+
documentStates: SyncTransactionState['documentStates'],
|
|
776
|
+
): SyncTransactionState['documentStates'] {
|
|
777
|
+
let next = documentStates
|
|
778
|
+
for (const [documentId, documentState] of Object.entries(documentStates)) {
|
|
779
|
+
if (!documentState) continue
|
|
780
|
+
if (documentState.subscriptions.length) continue
|
|
781
|
+
if (isAwaitingOwnEcho(documentState)) continue
|
|
782
|
+
next = omitProperty(next, documentId)
|
|
783
|
+
}
|
|
784
|
+
return next
|
|
785
|
+
}
|
|
786
|
+
|
|
704
787
|
export function manageSubscriberIds(
|
|
705
788
|
{state}: StoreContext<SyncTransactionState>,
|
|
706
789
|
handles: DocumentHandleLike[],
|
|
@@ -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
|
+
}
|