@sanity/sdk 2.17.0 → 2.18.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,7 +4,7 @@ import {describe, expect, it} from 'vitest'
4
4
 
5
5
  import {type Action, type DocumentAction} from './actions'
6
6
  import {ActionError, processActions} from './processActions/processActions'
7
- import {type DocumentSet} from './processMutations'
7
+ import {type DocumentSet, processMutations} from './processMutations'
8
8
 
9
9
  // Helper: Create a sample document that conforms to SanityDocument.
10
10
  const createDoc = (id: string, title: string, rev: string = 'initial'): SanityDocument => ({
@@ -443,6 +443,190 @@ describe('processActions', () => {
443
443
  expect(result.outgoingActions[0].actionType).toBe('sanity.action.document.edit')
444
444
  })
445
445
 
446
+ it('preserves inc/dec operations instead of collapsing them into sets', () => {
447
+ const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5, score: 10}
448
+ const base: DocumentSet = {'drafts.doc1': draft}
449
+ const working: DocumentSet = {'drafts.doc1': draft}
450
+ const actions: DocumentAction[] = [
451
+ {
452
+ documentId: 'doc1',
453
+ documentType: 'article',
454
+ type: 'document.edit',
455
+ patches: [{inc: {count: 1}, dec: {score: 3}}],
456
+ },
457
+ ]
458
+ const result = processActions({
459
+ actions,
460
+ transactionId,
461
+ base,
462
+ working,
463
+ timestamp,
464
+ grants: defaultGrants,
465
+ })
466
+
467
+ // the local working copy reflects the applied increments
468
+ expect(result.working['drafts.doc1']).toMatchObject({count: 6, score: 7})
469
+ // the outgoing patch keeps the operational inc/dec instead of a set.
470
+ // the setIfMissing seeds the base value in case the field was removed
471
+ // concurrently: the server runs setIfMissing before inc/dec, and inc on
472
+ // a missing path would otherwise fail the whole transaction
473
+ const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
474
+ expect(patches).toEqual([
475
+ {setIfMissing: {count: 5, score: 10}, inc: {count: 1}, dec: {score: 3}},
476
+ ])
477
+ })
478
+
479
+ it('accumulates multiple inc/dec records on the same path into one operation', () => {
480
+ const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
481
+ const base: DocumentSet = {'drafts.doc1': draft}
482
+ const working: DocumentSet = {'drafts.doc1': draft}
483
+ const actions: DocumentAction[] = [
484
+ {
485
+ documentId: 'doc1',
486
+ documentType: 'article',
487
+ type: 'document.edit',
488
+ patches: [{inc: {count: 1}}, {inc: {count: 2}}, {dec: {count: 4}}],
489
+ },
490
+ ]
491
+ const result = processActions({
492
+ actions,
493
+ transactionId,
494
+ base,
495
+ working,
496
+ timestamp,
497
+ grants: defaultGrants,
498
+ })
499
+
500
+ // 5 + 1 + 2 - 4 = 4 locally
501
+ expect(result.working['drafts.doc1']).toMatchObject({count: 4})
502
+ // the three records collapse into one accumulated dec (net -1) with the
503
+ // base value seeded for the concurrent-removal degradation path
504
+ const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
505
+ expect(patches).toEqual([{setIfMissing: {count: 5}, dec: {count: 1}}])
506
+ })
507
+
508
+ it('keeps the diffed set when an inc is combined with a set of the same field', () => {
509
+ const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
510
+ const base: DocumentSet = {'drafts.doc1': draft}
511
+ const working: DocumentSet = {'drafts.doc1': draft}
512
+ const actions: DocumentAction[] = [
513
+ {
514
+ documentId: 'doc1',
515
+ documentType: 'article',
516
+ type: 'document.edit',
517
+ // set to 100 first, then inc: the final value (101) is not
518
+ // explainable by the inc alone, so it must remain a set
519
+ patches: [{set: {count: 100}}, {inc: {count: 1}}],
520
+ },
521
+ ]
522
+ const result = processActions({
523
+ actions,
524
+ transactionId,
525
+ base,
526
+ working,
527
+ timestamp,
528
+ grants: defaultGrants,
529
+ })
530
+
531
+ expect(result.working['drafts.doc1']).toMatchObject({count: 101})
532
+ const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
533
+ expect(patches).toEqual([{set: {count: 101}}])
534
+ })
535
+
536
+ it('preserves inc operations targeting keyed array items', () => {
537
+ const draft = {
538
+ ...createDoc('drafts.doc1', 'Title', '1'),
539
+ items: [{_key: 'itemA', qty: 2}],
540
+ }
541
+ const base: DocumentSet = {'drafts.doc1': draft}
542
+ const working: DocumentSet = {'drafts.doc1': draft}
543
+ const actions: DocumentAction[] = [
544
+ {
545
+ documentId: 'doc1',
546
+ documentType: 'article',
547
+ type: 'document.edit',
548
+ patches: [{inc: {'items[_key=="itemA"].qty': 3}}],
549
+ },
550
+ ]
551
+ const result = processActions({
552
+ actions,
553
+ transactionId,
554
+ base,
555
+ working,
556
+ timestamp,
557
+ grants: defaultGrants,
558
+ })
559
+
560
+ expect(result.working['drafts.doc1']?.['items']).toEqual([{_key: 'itemA', qty: 5}])
561
+ const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
562
+ expect(patches).toEqual([
563
+ {setIfMissing: {'items[_key=="itemA"].qty': 2}, inc: {'items[_key=="itemA"].qty': 3}},
564
+ ])
565
+ })
566
+
567
+ it('preserves inc operations through the liveEdit mutation path', () => {
568
+ const liveDoc = {...createLiveEditDoc('live-doc', 'Title', '1'), count: 5}
569
+ const base: DocumentSet = {'live-doc': liveDoc}
570
+ const working: DocumentSet = {'live-doc': liveDoc}
571
+ const actions: DocumentAction[] = [
572
+ {
573
+ documentId: 'live-doc',
574
+ documentType: 'liveArticle',
575
+ type: 'document.edit',
576
+ liveEdit: true,
577
+ patches: [{inc: {count: 2}}],
578
+ },
579
+ ]
580
+ const result = processActions({
581
+ actions,
582
+ transactionId,
583
+ base,
584
+ working,
585
+ timestamp,
586
+ grants: defaultGrants,
587
+ })
588
+
589
+ expect(result.working['live-doc']).toMatchObject({count: 7})
590
+ expect(result.outgoingMutations).toEqual([
591
+ {patch: {id: 'live-doc', setIfMissing: {count: 5}, inc: {count: 2}}},
592
+ ])
593
+ })
594
+
595
+ it('degrades to the diffed set result when the inc target was removed concurrently', () => {
596
+ const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
597
+ const base: DocumentSet = {'drafts.doc1': draft}
598
+ const working: DocumentSet = {'drafts.doc1': draft}
599
+ const actions: DocumentAction[] = [
600
+ {
601
+ documentId: 'doc1',
602
+ documentType: 'article',
603
+ type: 'document.edit',
604
+ patches: [{inc: {count: 1}}],
605
+ },
606
+ ]
607
+ const result = processActions({
608
+ actions,
609
+ transactionId,
610
+ base,
611
+ working,
612
+ timestamp,
613
+ grants: defaultGrants,
614
+ })
615
+ const patch = result.outgoingActions.map((action) => 'patch' in action && action.patch)[0]
616
+
617
+ // simulate the server applying the emitted patch to a document where
618
+ // another client removed the counter in the meantime: setIfMissing
619
+ // seeds the base value, then inc applies, matching what the collapsed
620
+ // set would have written (instead of inc failing the transaction)
621
+ const {count: _count, ...draftWithoutCount} = draft
622
+ const serverResult = processMutations({
623
+ documents: {'drafts.doc1': draftWithoutCount as SanityDocument},
624
+ mutations: [{patch: {id: 'drafts.doc1', ...(patch as object)}}],
625
+ transactionId: 'txn-server',
626
+ })
627
+ expect(serverResult['drafts.doc1']).toMatchObject({count: 6})
628
+ })
629
+
446
630
  it('should edit a document when base and working diverge', () => {
447
631
  const publishedBase = createDoc('doc1', 'Original Boring Title')
448
632
  const publishedWorking = createDoc('doc1', 'Local Boring Title')
@@ -696,6 +696,129 @@ describe('applyRemoteDocument', () => {
696
696
  expect(newDocState?.unverifiedRevisions?.['txn10']).toBeUndefined()
697
697
  })
698
698
 
699
+ describe('fast-forward convergence of local', () => {
700
+ const docId = getDraftId(DocumentId('doc1'))
701
+ const otherDocId = getDraftId(DocumentId('doc2'))
702
+
703
+ const makeApplied = (overrides: Partial<AppliedTransaction>): AppliedTransaction => ({
704
+ transactionId: 'txnPending',
705
+ actions: [],
706
+ working: {},
707
+ previous: {},
708
+ base: {},
709
+ previousRevs: {},
710
+ timestamp: '2025-02-06T00:06:30.000Z',
711
+ outgoingActions: [],
712
+ outgoingMutations: [],
713
+ ...overrides,
714
+ })
715
+
716
+ const makeState = (applied: AppliedTransaction[]): SyncTransactionState => ({
717
+ queued: [],
718
+ applied,
719
+ outgoing: undefined,
720
+ grants,
721
+ documentStates: {
722
+ [docId]: {
723
+ id: docId,
724
+ subscriptions: ['sub1'],
725
+ local: {...exampleDoc, _id: docId, foo: 'optimistic'},
726
+ remote: {...exampleDoc, _id: docId, foo: 'old'},
727
+ unverifiedRevisions: {
728
+ txn10: {
729
+ transactionId: 'txn10',
730
+ documentId: docId,
731
+ previousRev: 'revOld',
732
+ timestamp: '2025-02-06T00:06:00.000Z',
733
+ },
734
+ },
735
+ },
736
+ },
737
+ })
738
+
739
+ const remote: RemoteDocument = {
740
+ type: 'sync',
741
+ documentId: docId,
742
+ document: {...exampleDoc, _id: docId, foo: 'server-truth'},
743
+ revision: 'txn10',
744
+ previousRev: 'revOld',
745
+ timestamp: '2025-02-06T00:07:00.000Z',
746
+ }
747
+
748
+ it('converges local to the server document when nothing else is pending', () => {
749
+ const events = new Subject<DocumentEvent>()
750
+ const newState = applyRemoteDocument(makeState([]), remote, events)
751
+ expect(newState.documentStates[docId]?.local).toEqual(remote.document)
752
+ })
753
+
754
+ it('converges local when pending work modifies only other documents', () => {
755
+ // a pending applied transaction on doc2 must not block convergence of
756
+ // doc1: nothing would ever revisit doc1's local otherwise
757
+ const otherDoc = {...exampleDoc, _id: otherDocId}
758
+ const pendingOnOtherDoc = makeApplied({
759
+ working: {[otherDocId]: {...otherDoc, _rev: 'txnPending'}},
760
+ previous: {[otherDocId]: otherDoc},
761
+ base: {[otherDocId]: otherDoc},
762
+ previousRevs: {[otherDocId]: 'txn0'},
763
+ })
764
+ const events = new Subject<DocumentEvent>()
765
+ const newState = applyRemoteDocument(makeState([pendingOnOtherDoc]), remote, events)
766
+ expect(newState.documentStates[docId]?.local).toEqual(remote.document)
767
+ })
768
+
769
+ it('converges local when pending work carries this document unmodified', () => {
770
+ // a draft edit carries the published document in its working set
771
+ // without changing it; presence alone must not block convergence
772
+ const thisDoc = {...exampleDoc, _id: docId}
773
+ const pendingCarryingUnmodified = makeApplied({
774
+ working: {
775
+ [docId]: thisDoc,
776
+ [otherDocId]: {...exampleDoc, _id: otherDocId, _rev: 'txnPending'},
777
+ },
778
+ previous: {[docId]: thisDoc, [otherDocId]: {...exampleDoc, _id: otherDocId}},
779
+ base: {[docId]: thisDoc, [otherDocId]: {...exampleDoc, _id: otherDocId}},
780
+ previousRevs: {[docId]: thisDoc._rev, [otherDocId]: 'txn0'},
781
+ })
782
+ const events = new Subject<DocumentEvent>()
783
+ const newState = applyRemoteDocument(makeState([pendingCarryingUnmodified]), remote, events)
784
+ expect(newState.documentStates[docId]?.local).toEqual(remote.document)
785
+ })
786
+
787
+ it('keeps the optimistic local when pending work modifies this document', () => {
788
+ const optimistic = {...exampleDoc, _id: docId, _rev: 'txnPending', foo: 'optimistic'}
789
+ const pendingOnThisDoc = makeApplied({
790
+ working: {[docId]: optimistic},
791
+ previous: {[docId]: {...exampleDoc, _id: docId}},
792
+ base: {[docId]: {...exampleDoc, _id: docId}},
793
+ previousRevs: {[docId]: 'txn0'},
794
+ })
795
+ const state = makeState([pendingOnThisDoc])
796
+ const events = new Subject<DocumentEvent>()
797
+ const newState = applyRemoteDocument(state, remote, events)
798
+ expect(newState.documentStates[docId]?.local).toBe(state.documentStates[docId]?.local)
799
+ expect(newState.documentStates[docId]?.remote).toEqual(remote.document)
800
+ })
801
+
802
+ it('keeps the optimistic local when a different in-flight transaction modifies this document', () => {
803
+ const optimistic = {...exampleDoc, _id: docId, _rev: 'txnOutgoing', foo: 'optimistic'}
804
+ const outgoing = {
805
+ ...makeApplied({
806
+ transactionId: 'txnOutgoing',
807
+ working: {[docId]: optimistic},
808
+ previous: {[docId]: {...exampleDoc, _id: docId}},
809
+ base: {[docId]: {...exampleDoc, _id: docId}},
810
+ previousRevs: {[docId]: 'txn0'},
811
+ }),
812
+ disableBatching: false,
813
+ batchedTransactionIds: ['txnOutgoing'],
814
+ }
815
+ const state = {...makeState([]), outgoing}
816
+ const events = new Subject<DocumentEvent>()
817
+ const newState = applyRemoteDocument(state, remote, events)
818
+ expect(newState.documentStates[docId]?.local).toBe(state.documentStates[docId]?.local)
819
+ })
820
+ })
821
+
699
822
  it('rebases local changes when no matching unverified revision is found', () => {
700
823
  // In this branch we simply let processActions rebase so that the local becomes the remote.
701
824
  const docId = getDraftId(DocumentId('doc1'))
@@ -5,7 +5,7 @@ import {type DocumentHandle} from '../config/sanityConfig'
5
5
  import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
6
6
  import {type StoreContext} from '../store/defineStore'
7
7
  import {insecureRandomId} from '../utils/ids'
8
- import {omitProperty} from '../utils/object'
8
+ import {isDeepEqual, omitProperty} from '../utils/object'
9
9
  import {setCleanupTimeout} from '../utils/setCleanupTimeout'
10
10
  import {type Action} from './actions'
11
11
  import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
@@ -534,6 +534,34 @@ export function applyRemoteDocument(
534
534
  // matches the previous revision we expected, we can "fast-forward" and skip
535
535
  // rebasing local changes on top of this new base
536
536
  if (revisionToVerify && revisionToVerify.previousRev === previousRev) {
537
+ // even on a clean fast-forward, the server's result can differ from our
538
+ // optimistic prediction: a publish derives the published content from the
539
+ // server's copy of the draft, which may carry concurrent edits our
540
+ // prediction never saw. once nothing else is pending locally for this
541
+ // document (no applied transaction and no in-flight transaction, other
542
+ // than the one this event just confirmed, modifies it), converge `local`
543
+ // to the server's document. while local work that modifies this document
544
+ // is pending, keep the optimistic `local` so those changes stay visible;
545
+ // that work's own echo (or rebase) recomputes `local` later.
546
+ //
547
+ // "modifies" matters: pending work on other documents must not block
548
+ // convergence here (nothing would ever revisit this document's `local`),
549
+ // and a pending draft edit carries the published document in its working
550
+ // set without changing it, so a presence check would block published-doc
551
+ // convergence whenever a draft edit is pending. this is the same
552
+ // modified-test `transitionAppliedTransactionsToOutgoing` uses
553
+ const modifiesDocument = (transaction: AppliedTransaction) =>
554
+ transaction.working[documentId]?._rev !== transaction.previousRevs[documentId]
555
+ const nothingElsePending =
556
+ !prev.applied.some(modifiesDocument) &&
557
+ (!prev.outgoing ||
558
+ prev.outgoing.transactionId === revision ||
559
+ !modifiesDocument(prev.outgoing))
560
+ const local =
561
+ nothingElsePending && !isDeepEqual(prevDocState.local, document)
562
+ ? document
563
+ : prevDocState.local
564
+
537
565
  return {
538
566
  ...prev,
539
567
  documentStates: {
@@ -542,6 +570,7 @@ export function applyRemoteDocument(
542
570
  ...prevDocState,
543
571
  remote: document,
544
572
  remoteRev: revision,
573
+ local,
545
574
  unverifiedRevisions,
546
575
  },
547
576
  },
@@ -592,6 +621,12 @@ export function applyRemoteDocument(
592
621
  }
593
622
  }
594
623
 
624
+ // when the recompute lands on a value deep-equal to the current local
625
+ // (the common case for echoes of our own transactions), keep the previous
626
+ // reference so subscribers don't re-render for an identical value
627
+ const nextLocal = working[documentId]
628
+ const local = isDeepEqual(prevDocState.local, nextLocal) ? prevDocState.local : nextLocal
629
+
595
630
  return {
596
631
  ...prev,
597
632
  applied: nextApplied,
@@ -601,7 +636,7 @@ export function applyRemoteDocument(
601
636
  ...prevDocState,
602
637
  remote: document,
603
638
  remoteRev: revision,
604
- local: working[documentId],
639
+ local,
605
640
  unverifiedRevisions,
606
641
  },
607
642
  },
@@ -49,7 +49,8 @@ describe('createSharedListener', () => {
49
49
  '*',
50
50
  {},
51
51
  {
52
- events: ['mutation', 'welcome', 'reconnect'],
52
+ events: ['mutation', 'welcome', 'welcomeback', 'reconnect', 'reset'],
53
+ enableResume: true,
53
54
  includeResult: false,
54
55
  includeAllVersions: true,
55
56
  tag: 'document-listener',
@@ -16,6 +16,7 @@ import {
16
16
  import {getClientState} from '../client/clientStore'
17
17
  import {type DocumentResource} from '../config/sanityConfig'
18
18
  import {type SanityInstance} from '../store/createSanityInstance'
19
+ import {dedupeListenerEvents, groupTransactionEvents} from './listenerEventOperators'
19
20
 
20
21
  const API_VERSION = 'v2025-05-06'
21
22
 
@@ -41,7 +42,12 @@ export function createSharedListener(
41
42
  '*',
42
43
  {},
43
44
  {
44
- events: ['mutation', 'welcome', 'reconnect'],
45
+ // with `enableResume`, a reconnect replays the events missed while
46
+ // offline (emitting `welcomeback`) instead of forcing a refetch of
47
+ // every subscribed document. when the backend cannot resume, it
48
+ // emits `welcome` or `reset`, both of which trigger a refetch
49
+ events: ['mutation', 'welcome', 'welcomeback', 'reconnect', 'reset'],
50
+ enableResume: true,
45
51
  includeResult: false,
46
52
  // the document store handles version documents, so we need to include all versions
47
53
  includeAllVersions: true,
@@ -53,6 +59,11 @@ export function createSharedListener(
53
59
  },
54
60
  ),
55
61
  ),
62
+ // resumed events are delivered at-least-once, so drop replayed duplicates
63
+ dedupeListenerEvents(),
64
+ // hold multi-document transactions (e.g. publish) until complete so
65
+ // per-document processing never observes half a transaction
66
+ groupTransactionEvents(),
56
67
  takeUntil(dispose$),
57
68
  share(),
58
69
  )