@sanity/sdk 3.0.0-rc.2 → 3.1.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.
Files changed (102) hide show
  1. package/dist/_exports/_internal.d.ts +25 -3
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +127 -9
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/_exports/agent.d.ts +1 -1
  6. package/dist/_exports/agent.js +1 -1
  7. package/dist/_exports/comlink.d.ts +1 -1
  8. package/dist/_exports/comlink.js +2 -3
  9. package/dist/_exports/comlink.js.map +1 -1
  10. package/dist/{authStore-DKFX8fn7.d.ts → authStore-C-OEkGaM.d.ts} +30 -44
  11. package/dist/authStore-C-OEkGaM.d.ts.map +1 -0
  12. package/dist/{authStore-CtE9bLHW.js → clientStore-gUALQ0qW.js} +269 -11
  13. package/dist/{authStore-CtE9bLHW.js.map → clientStore-gUALQ0qW.js.map} +1 -1
  14. package/dist/{createSanityInstance-DUI1bGIg.d.ts → createSanityInstance-DCePpBYW.d.ts} +2 -2
  15. package/dist/{createSanityInstance-DUI1bGIg.d.ts.map → createSanityInstance-DCePpBYW.d.ts.map} +1 -1
  16. package/dist/{getNodeState-CR3__B2D.js → getNodeState-E0hjqFOo.js} +2 -3
  17. package/dist/{getNodeState-CR3__B2D.js.map → getNodeState-E0hjqFOo.js.map} +1 -1
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +168 -176
  20. package/dist/index.js.map +1 -1
  21. package/dist/{createStateSourceAction-BOKtqFNm.js → object-BwynGPRZ.js} +87 -3
  22. package/dist/object-BwynGPRZ.js.map +1 -0
  23. package/dist/{queryStore-DPf9sN3V.js → version-C90rNWaX.js} +48 -16
  24. package/dist/version-C90rNWaX.js.map +1 -0
  25. package/package.json +4 -4
  26. package/src/_exports/_internal.ts +1 -0
  27. package/src/_exports/index.ts +7 -4
  28. package/src/access/checkPermissions.test.ts +1 -1
  29. package/src/access/checkPermissions.ts +1 -1
  30. package/src/applications/applications.test.ts +4 -4
  31. package/src/applications/applications.ts +4 -4
  32. package/src/auth/handleAuthCallback.test.ts +2 -2
  33. package/src/auth/handleAuthCallback.ts +1 -1
  34. package/src/auth/logout.test.ts +1 -1
  35. package/src/auth/logout.ts +1 -1
  36. package/src/auth/refreshStampedToken.ts +1 -1
  37. package/src/auth/studioModeAuth.test.ts +1 -1
  38. package/src/auth/studioModeAuth.ts +1 -1
  39. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -2
  40. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -1
  41. package/src/comments/addonDatasetStore.test.ts +2 -2
  42. package/src/comments/addonDatasetStore.ts +2 -2
  43. package/src/comments/commentActions.ts +6 -5
  44. package/src/comments/commentsStore.ts +4 -4
  45. package/src/datasets/datasets.test.ts +44 -9
  46. package/src/datasets/datasets.ts +24 -23
  47. package/src/document/applyDocumentActions.ts +2 -6
  48. package/src/document/documentConstants.ts +9 -0
  49. package/src/document/documentStore.concurrency.test.ts +2 -1
  50. package/src/document/documentStore.test.ts +87 -17
  51. package/src/document/documentStore.ts +5 -5
  52. package/src/document/permissions.ts +2 -1
  53. package/src/document/processActions.test.ts +2 -1
  54. package/src/document/processMutations.ts +3 -2
  55. package/src/document/reducers.test.ts +224 -0
  56. package/src/document/reducers.ts +110 -27
  57. package/src/favorites/favorites.test.ts +64 -24
  58. package/src/favorites/favorites.ts +73 -22
  59. package/src/installations/installations.test.ts +2 -2
  60. package/src/installations/installations.ts +2 -2
  61. package/src/organization/organization.test.ts +2 -2
  62. package/src/organization/organization.ts +1 -1
  63. package/src/organizations/organizations.test.ts +2 -2
  64. package/src/organizations/organizations.ts +1 -1
  65. package/src/presence/presenceStore.test.ts +15 -22
  66. package/src/presence/presenceStore.ts +3 -2
  67. package/src/project/project.test.ts +3 -3
  68. package/src/project/project.ts +1 -1
  69. package/src/projection/getProjectionState.test.ts +6 -4
  70. package/src/projection/getProjectionState.ts +2 -2
  71. package/src/projects/projects.test.ts +2 -2
  72. package/src/projects/projects.ts +1 -1
  73. package/src/query/queryStore.ts +3 -3
  74. package/src/store/createSanityInstance.test.ts +1 -1
  75. package/src/store/createSanityInstance.ts +4 -4
  76. package/src/store/createStoreInstance.test.ts +0 -5
  77. package/src/store/fetcherStore.test.ts +23 -1
  78. package/src/store/fetcherStore.ts +3 -3
  79. package/src/telemetry/telemetryManager.test.ts +1 -1
  80. package/src/telemetry/telemetryManager.ts +2 -2
  81. package/src/users/usersStore.test.ts +1 -1
  82. package/src/users/usersStore.ts +5 -5
  83. package/src/utils/ids.test.ts +40 -15
  84. package/src/utils/ids.ts +47 -2
  85. package/dist/authStore-DKFX8fn7.d.ts.map +0 -1
  86. package/dist/clientStore-BtE2-q2i.js +0 -90
  87. package/dist/clientStore-BtE2-q2i.js.map +0 -1
  88. package/dist/createStateSourceAction-BOKtqFNm.js.map +0 -1
  89. package/dist/getEnv-D5RGkQLu.js +0 -8
  90. package/dist/getEnv-D5RGkQLu.js.map +0 -1
  91. package/dist/logger-DAtJb5RQ.js +0 -173
  92. package/dist/logger-DAtJb5RQ.js.map +0 -1
  93. package/dist/object-CxXlSVg7.js +0 -83
  94. package/dist/object-CxXlSVg7.js.map +0 -1
  95. package/dist/queryStore-DPf9sN3V.js.map +0 -1
  96. package/dist/rolldown-runtime-vyAXikos.js +0 -9
  97. package/dist/telemetryManager-Dq_qCkeI.js +0 -127
  98. package/dist/telemetryManager-Dq_qCkeI.js.map +0 -1
  99. package/dist/version-BChjsi4u.js +0 -9
  100. package/dist/version-BChjsi4u.js.map +0 -1
  101. package/src/utils/createFetcherStore.test.ts +0 -171
  102. package/src/utils/createFetcherStore.ts +0 -284
@@ -5,6 +5,7 @@ import {
5
5
  type SanityDocument,
6
6
  } from '@sanity/types'
7
7
 
8
+ import {randomUuid} from '../utils/ids'
8
9
  import {
9
10
  dec,
10
11
  diffMatchPatch,
@@ -63,8 +64,8 @@ const patchOperations = {
63
64
  * [- source](https://www.sanity.io/docs/http-mutations#c732f27330a4)
64
65
  */
65
66
  export function getId(id?: string): string {
66
- if (!id || typeof id !== 'string') return crypto.randomUUID()
67
- if (id.endsWith('.')) return `${id}${crypto.randomUUID()}`
67
+ if (!id || typeof id !== 'string') return randomUuid()
68
+ if (id.endsWith('.')) return `${id}${randomUuid()}`
68
69
  return id
69
70
  }
70
71
 
@@ -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', () => {
@@ -4,11 +4,11 @@ import {type Mutation, type PatchOperations, type SanityDocumentLike} from '@san
4
4
  import {type DocumentHandle} from '../config/sanityConfig'
5
5
  import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
6
6
  import {type StoreContext} from '../store/defineStore'
7
- import {insecureRandomId} from '../utils/ids'
7
+ import {randomId} 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,29 +709,88 @@ 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[],
707
790
  ): () => void {
708
791
  const documentIds = getDocumentIdsFromHandleLikes(handles)
709
792
 
710
- const subscriptionId = insecureRandomId()
793
+ const subscriptionId = randomId(16)
711
794
  state.set('addSubscribers', (prev) =>
712
795
  documentIds.reduce(
713
796
  (acc, id) => addSubscriptionIdToDocument(acc, id, subscriptionId),
@@ -1,12 +1,12 @@
1
1
  import {type Node} from '@sanity/comlink'
2
- import {firstValueFrom, of, Subject} from 'rxjs'
2
+ import {of, Subject} from 'rxjs'
3
3
  import {describe, expect, it, type Mock, vi} from 'vitest'
4
4
 
5
5
  import {getNodeState, type NodeState} from '../comlink/node/getNodeState'
6
6
  import {type FrameMessage, type WindowMessage} from '../comlink/types'
7
7
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8
8
  import {type StateSource} from '../store/createStateSourceAction'
9
- import {getFavoritesState, resolveFavoritesState} from './favorites'
9
+ import {favorites, setFavorite} from './favorites'
10
10
 
11
11
  vi.mock('../comlink/node/getNodeState', () => ({
12
12
  getNodeState: vi.fn(),
@@ -62,8 +62,8 @@ describe('favoritesStore', () => {
62
62
  it('creates different keys for different contexts with schema name', async () => {
63
63
  setupMockStateSource()
64
64
  // Make two fetches with different document IDs
65
- await resolveFavoritesState(instance!, mockContext)
66
- await resolveFavoritesState(instance!, {
65
+ await favorites.resolveState(instance!, mockContext)
66
+ await favorites.resolveState(instance!, {
67
67
  ...mockContext,
68
68
  documentId: 'different',
69
69
  })
@@ -79,8 +79,8 @@ describe('favoritesStore', () => {
79
79
  it('creates different keys for contexts without schema name', async () => {
80
80
  setupMockStateSource()
81
81
  // Make two fetches with different document IDs
82
- await resolveFavoritesState(instance!, mockContextNoSchema)
83
- await resolveFavoritesState(instance!, {
82
+ await favorites.resolveState(instance!, mockContextNoSchema)
83
+ await favorites.resolveState(instance!, {
84
84
  ...mockContextNoSchema,
85
85
  documentId: 'different',
86
86
  })
@@ -110,7 +110,7 @@ describe('favoritesStore', () => {
110
110
  it('fetches favorite status and handles success', async () => {
111
111
  const mockResponse = {isFavorited: true}
112
112
  setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue(mockResponse)})
113
- const result = await resolveFavoritesState(instance!, mockContext)
113
+ const result = await favorites.resolveState(instance!, mockContext)
114
114
  expect(result).toEqual(mockResponse)
115
115
  expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/query', {
116
116
  document: {
@@ -128,7 +128,7 @@ describe('favoritesStore', () => {
128
128
  it('handles error and returns default response', async () => {
129
129
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
130
130
  setupMockStateSource({fetchImpl: vi.fn().mockRejectedValue(new Error('Failed to fetch'))})
131
- const result = await resolveFavoritesState(instance!, mockContext)
131
+ const result = await favorites.resolveState(instance!, mockContext)
132
132
  expect(result).toEqual({isFavorited: false})
133
133
  expect(consoleErrorSpy).toHaveBeenCalledWith(
134
134
  'Favorites service connection error',
@@ -137,25 +137,19 @@ describe('favoritesStore', () => {
137
137
  consoleErrorSpy.mockRestore()
138
138
  })
139
139
 
140
- it('shares observable between multiple subscribers and cleans up', async () => {
140
+ it('serves subsequent reads within staleTime from the cache', async () => {
141
141
  const mockResponse = {isFavorited: true}
142
142
  setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue(mockResponse)})
143
- const state = getFavoritesState(instance!, mockContext)
144
- // First subscriber
145
- const sub1 = state.subscribe()
146
- await firstValueFrom(state.observable)
143
+ const first = await favorites.resolveState(instance!, mockContext)
144
+ expect(first).toEqual(mockResponse)
147
145
  expect(mockFetch).toHaveBeenCalledTimes(1)
148
- // Second subscriber should use cached response
149
- const state2 = getFavoritesState(instance!, mockContext)
150
- const sub2 = state2.subscribe()
151
- await firstValueFrom(state2.observable)
146
+ // Second read is fresh (within staleTime) — no second fetch
147
+ const second = await favorites.resolveState(instance!, mockContext)
148
+ expect(second).toEqual(mockResponse)
152
149
  expect(mockFetch).toHaveBeenCalledTimes(1)
153
- // Cleanup
154
- sub1()
155
- sub2()
156
150
  })
157
151
 
158
- it('reuses active fetch via createFetcherStore/shareReplay when called again while pending', async () => {
152
+ it('dedupes against the in-flight fetch when called again while pending', async () => {
159
153
  vi.useFakeTimers()
160
154
  let resolveFetch: (value: {isFavorited: boolean}) => void
161
155
  const fetchPromise = new Promise<{isFavorited: boolean}>((resolve) => {
@@ -175,13 +169,15 @@ describe('favoritesStore', () => {
175
169
  } as unknown as StateSource<NodeState>
176
170
  vi.mocked(getNodeState).mockReturnValue(mockStateSource)
177
171
  // Call 1: Triggers the actual fetch
178
- const promise1 = resolveFavoritesState(instance!, mockContext)
172
+ const promise1 = favorites.resolveState(instance!, mockContext)
173
+ // Let the deferred fetch start and subscribe to the node state
174
+ await vi.advanceTimersByTimeAsync(1)
179
175
  // Simulate node becoming connected
180
176
  subject.next({node: mockNode, status: 'connected'})
181
177
  await vi.advanceTimersByTimeAsync(1)
182
178
  expect(fetchSpy).toHaveBeenCalledTimes(1)
183
- // Call 2: Should reuse the pending fetch via createFetcherStore/shareReplay
184
- const promise2 = resolveFavoritesState(instance!, mockContext)
179
+ // Call 2: Should reuse the pending fetch
180
+ const promise2 = favorites.resolveState(instance!, mockContext)
185
181
  await vi.advanceTimersByTimeAsync(1)
186
182
  expect(fetchSpy).toHaveBeenCalledTimes(1)
187
183
  // Resolve the underlying fetch
@@ -195,4 +191,48 @@ describe('favoritesStore', () => {
195
191
  vi.useRealTimers()
196
192
  })
197
193
  })
194
+
195
+ describe('setFavorite', () => {
196
+ beforeEach(() => {
197
+ vi.resetAllMocks()
198
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
199
+ setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue({success: true})})
200
+ })
201
+
202
+ afterEach(() => {
203
+ instance?.dispose()
204
+ })
205
+
206
+ it('sends the added event with schemaName and resolves to the new state', async () => {
207
+ const result = await setFavorite(instance!, {...mockContext, isFavorited: true})
208
+ expect(result.data).toEqual({isFavorited: true})
209
+ expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/mutate', {
210
+ eventType: 'added',
211
+ document: {
212
+ id: mockContext.documentId,
213
+ type: mockContext.documentType,
214
+ resource: {
215
+ id: mockContext.resourceId,
216
+ type: mockContext.resourceType,
217
+ schemaName: mockContext.schemaName,
218
+ },
219
+ },
220
+ })
221
+ })
222
+
223
+ it('sends the removed event and omits schemaName when absent', async () => {
224
+ const result = await setFavorite(instance!, {...mockContextNoSchema, isFavorited: false})
225
+ expect(result.data).toEqual({isFavorited: false})
226
+ const payload = mockFetch.mock.calls[0][1]
227
+ expect(payload.eventType).toBe('removed')
228
+ expect(payload.document.resource).not.toHaveProperty('schemaName')
229
+ })
230
+
231
+ it('rejects when the server reports failure', async () => {
232
+ setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue({success: false})})
233
+ await expect(setFavorite(instance!, {...mockContext, isFavorited: true})).rejects.toThrow(
234
+ 'Failed to update favorite status',
235
+ )
236
+ })
237
+ })
198
238
  })