@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.
@@ -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', () => {
@@ -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
- return {...next, outgoing: undefined}
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
- documentStates: Object.fromEntries(
450
- Object.entries(base.documentStates)
451
- .filter((e): e is [string, DocumentState] => !!e[1])
452
- .map(([documentId, {unverifiedRevisions = {}, local, ...documentState}]) => {
453
- const next: DocumentState = {
454
- ...documentState,
455
- local: documentId in working ? working[documentId] : local,
456
- unverifiedRevisions:
457
- prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
458
- ? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
459
- : unverifiedRevisions,
460
- }
461
- return [documentId, next] as const
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
- if (!subscriptions.length) {
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: 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
+ }