@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.
- package/dist/_exports/_internal.d.ts +25 -3
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +127 -9
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +1 -1
- package/dist/_exports/comlink.js +2 -3
- package/dist/_exports/comlink.js.map +1 -1
- package/dist/{authStore-DKFX8fn7.d.ts → authStore-C-OEkGaM.d.ts} +30 -44
- package/dist/authStore-C-OEkGaM.d.ts.map +1 -0
- package/dist/{authStore-CtE9bLHW.js → clientStore-gUALQ0qW.js} +269 -11
- package/dist/{authStore-CtE9bLHW.js.map → clientStore-gUALQ0qW.js.map} +1 -1
- package/dist/{createSanityInstance-DUI1bGIg.d.ts → createSanityInstance-DCePpBYW.d.ts} +2 -2
- package/dist/{createSanityInstance-DUI1bGIg.d.ts.map → createSanityInstance-DCePpBYW.d.ts.map} +1 -1
- package/dist/{getNodeState-CR3__B2D.js → getNodeState-E0hjqFOo.js} +2 -3
- package/dist/{getNodeState-CR3__B2D.js.map → getNodeState-E0hjqFOo.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +168 -176
- package/dist/index.js.map +1 -1
- package/dist/{createStateSourceAction-BOKtqFNm.js → object-BwynGPRZ.js} +87 -3
- package/dist/object-BwynGPRZ.js.map +1 -0
- package/dist/{queryStore-DPf9sN3V.js → version-C90rNWaX.js} +48 -16
- package/dist/version-C90rNWaX.js.map +1 -0
- package/package.json +4 -4
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/index.ts +7 -4
- package/src/access/checkPermissions.test.ts +1 -1
- package/src/access/checkPermissions.ts +1 -1
- package/src/applications/applications.test.ts +4 -4
- package/src/applications/applications.ts +4 -4
- package/src/auth/handleAuthCallback.test.ts +2 -2
- package/src/auth/handleAuthCallback.ts +1 -1
- package/src/auth/logout.test.ts +1 -1
- package/src/auth/logout.ts +1 -1
- package/src/auth/refreshStampedToken.ts +1 -1
- package/src/auth/studioModeAuth.test.ts +1 -1
- package/src/auth/studioModeAuth.ts +1 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -2
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -1
- package/src/comments/addonDatasetStore.test.ts +2 -2
- package/src/comments/addonDatasetStore.ts +2 -2
- package/src/comments/commentActions.ts +6 -5
- package/src/comments/commentsStore.ts +4 -4
- package/src/datasets/datasets.test.ts +44 -9
- package/src/datasets/datasets.ts +24 -23
- package/src/document/applyDocumentActions.ts +2 -6
- package/src/document/documentConstants.ts +9 -0
- package/src/document/documentStore.concurrency.test.ts +2 -1
- package/src/document/documentStore.test.ts +87 -17
- package/src/document/documentStore.ts +5 -5
- package/src/document/permissions.ts +2 -1
- package/src/document/processActions.test.ts +2 -1
- package/src/document/processMutations.ts +3 -2
- package/src/document/reducers.test.ts +224 -0
- package/src/document/reducers.ts +110 -27
- package/src/favorites/favorites.test.ts +64 -24
- package/src/favorites/favorites.ts +73 -22
- package/src/installations/installations.test.ts +2 -2
- package/src/installations/installations.ts +2 -2
- package/src/organization/organization.test.ts +2 -2
- package/src/organization/organization.ts +1 -1
- package/src/organizations/organizations.test.ts +2 -2
- package/src/organizations/organizations.ts +1 -1
- package/src/presence/presenceStore.test.ts +15 -22
- package/src/presence/presenceStore.ts +3 -2
- package/src/project/project.test.ts +3 -3
- package/src/project/project.ts +1 -1
- package/src/projection/getProjectionState.test.ts +6 -4
- package/src/projection/getProjectionState.ts +2 -2
- package/src/projects/projects.test.ts +2 -2
- package/src/projects/projects.ts +1 -1
- package/src/query/queryStore.ts +3 -3
- package/src/store/createSanityInstance.test.ts +1 -1
- package/src/store/createSanityInstance.ts +4 -4
- package/src/store/createStoreInstance.test.ts +0 -5
- package/src/store/fetcherStore.test.ts +23 -1
- package/src/store/fetcherStore.ts +3 -3
- package/src/telemetry/telemetryManager.test.ts +1 -1
- package/src/telemetry/telemetryManager.ts +2 -2
- package/src/users/usersStore.test.ts +1 -1
- package/src/users/usersStore.ts +5 -5
- package/src/utils/ids.test.ts +40 -15
- package/src/utils/ids.ts +47 -2
- package/dist/authStore-DKFX8fn7.d.ts.map +0 -1
- package/dist/clientStore-BtE2-q2i.js +0 -90
- package/dist/clientStore-BtE2-q2i.js.map +0 -1
- package/dist/createStateSourceAction-BOKtqFNm.js.map +0 -1
- package/dist/getEnv-D5RGkQLu.js +0 -8
- package/dist/getEnv-D5RGkQLu.js.map +0 -1
- package/dist/logger-DAtJb5RQ.js +0 -173
- package/dist/logger-DAtJb5RQ.js.map +0 -1
- package/dist/object-CxXlSVg7.js +0 -83
- package/dist/object-CxXlSVg7.js.map +0 -1
- package/dist/queryStore-DPf9sN3V.js.map +0 -1
- package/dist/rolldown-runtime-vyAXikos.js +0 -9
- package/dist/telemetryManager-Dq_qCkeI.js +0 -127
- package/dist/telemetryManager-Dq_qCkeI.js.map +0 -1
- package/dist/version-BChjsi4u.js +0 -9
- package/dist/version-BChjsi4u.js.map +0 -1
- package/src/utils/createFetcherStore.test.ts +0 -171
- 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
|
|
67
|
-
if (id.endsWith('.')) return `${id}${
|
|
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', () => {
|
package/src/document/reducers.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,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
|
-
|
|
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[],
|
|
707
790
|
): () => void {
|
|
708
791
|
const documentIds = getDocumentIdsFromHandleLikes(handles)
|
|
709
792
|
|
|
710
|
-
const subscriptionId =
|
|
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 {
|
|
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 {
|
|
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
|
|
66
|
-
await
|
|
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
|
|
83
|
-
await
|
|
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
|
|
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
|
|
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('
|
|
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
|
|
144
|
-
|
|
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
|
|
149
|
-
const
|
|
150
|
-
|
|
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('
|
|
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 =
|
|
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
|
|
184
|
-
const promise2 =
|
|
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
|
})
|