@sanity/sdk 3.0.0-rc.1 → 3.0.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/README.md +12 -0
- package/dist/_exports/_internal.d.ts +71 -3
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +332 -24
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +65 -0
- package/dist/_exports/agent.d.ts.map +1 -0
- package/dist/_exports/agent.js +71 -0
- package/dist/_exports/agent.js.map +1 -0
- package/dist/_exports/comlink.d.ts +118 -0
- package/dist/_exports/comlink.d.ts.map +1 -0
- package/dist/_exports/comlink.js +69 -0
- package/dist/_exports/comlink.js.map +1 -0
- package/dist/authStore-C0o1NOxW.d.ts +3165 -0
- package/dist/authStore-C0o1NOxW.d.ts.map +1 -0
- package/dist/authStore-CtE9bLHW.js +754 -0
- package/dist/authStore-CtE9bLHW.js.map +1 -0
- package/dist/clientStore-BtE2-q2i.js +90 -0
- package/dist/clientStore-BtE2-q2i.js.map +1 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
- package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
- package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
- package/dist/getEnv-D5RGkQLu.js +8 -0
- package/dist/getEnv-D5RGkQLu.js.map +1 -0
- package/dist/getNodeState-CR3__B2D.js +67 -0
- package/dist/getNodeState-CR3__B2D.js.map +1 -0
- package/dist/index.d.ts +3 -2335
- package/dist/index.js +4048 -3878
- package/dist/index.js.map +1 -1
- package/dist/logger-DAtJb5RQ.js +173 -0
- package/dist/logger-DAtJb5RQ.js.map +1 -0
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/queryStore-C7yMHPUI.js +1346 -0
- package/dist/queryStore-C7yMHPUI.js.map +1 -0
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
- package/dist/telemetryManager-UIEiMtC0.js +127 -0
- package/dist/telemetryManager-UIEiMtC0.js.map +1 -0
- package/dist/version-D8RP_Q59.js +9 -0
- package/dist/version-D8RP_Q59.js.map +1 -0
- package/package.json +11 -17
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/agent.ts +19 -0
- package/src/_exports/comlink.ts +16 -0
- package/src/_exports/index.ts +45 -90
- package/src/applications/applications.test-d.ts +5 -0
- package/src/applications/applications.ts +5 -2
- package/src/auth/authMode.test.ts +0 -25
- package/src/auth/authMode.ts +3 -6
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/authStore.ts +0 -1
- package/src/comments/addonDatasetStore.test.ts +215 -0
- package/src/comments/addonDatasetStore.ts +249 -0
- package/src/comments/buildCommentThreads.test.ts +83 -0
- package/src/comments/buildCommentThreads.ts +54 -0
- package/src/comments/commentActions.test.ts +540 -0
- package/src/comments/commentActions.ts +555 -0
- package/src/comments/commentFieldPath.test.ts +40 -0
- package/src/comments/commentFieldPath.ts +44 -0
- package/src/comments/commentsConstants.ts +80 -0
- package/src/comments/commentsStore.test.ts +419 -0
- package/src/comments/commentsStore.ts +483 -0
- package/src/comments/normalizeComment.test.ts +116 -0
- package/src/comments/normalizeComment.ts +45 -0
- package/src/comments/observeComments.test.ts +193 -0
- package/src/comments/observeComments.ts +113 -0
- package/src/comments/reducers.test.ts +277 -0
- package/src/comments/reducers.ts +316 -0
- package/src/comments/types.ts +249 -0
- package/src/comments/weakenReferences.test.ts +47 -0
- package/src/comments/weakenReferences.ts +29 -0
- package/src/config/sanityConfig.ts +3 -75
- package/src/datasets/datasets.test.ts +44 -9
- package/src/datasets/datasets.ts +24 -23
- package/src/document/applyDocumentActions.test.ts +8 -12
- package/src/document/documentConstants.ts +17 -0
- package/src/document/documentStore.test.ts +173 -19
- package/src/document/documentStore.ts +36 -5
- package/src/document/patchOperations.test.ts +27 -0
- package/src/document/patchOperations.ts +10 -1
- package/src/document/permissions.test.ts +3 -1
- package/src/document/reducers.test.ts +357 -0
- package/src/document/reducers.ts +125 -27
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/favorites/favorites.test.ts +64 -24
- package/src/favorites/favorites.ts +73 -22
- package/src/installations/installations.test-d.ts +5 -0
- package/src/installations/installations.ts +4 -0
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/src/preview/types.ts +0 -25
- package/src/projection/projectionStore.test.ts +0 -8
- package/src/projection/types.ts +0 -8
- package/src/query/queryStore.ts +7 -6
- package/src/store/createActionBinder.test.ts +51 -29
- package/src/store/createActionBinder.ts +16 -62
- package/src/store/createSanityInstance.test.ts +0 -68
- package/src/store/createSanityInstance.ts +0 -63
- package/src/store/createStateSourceAction.ts +1 -1
- package/src/store/fetcherStore.test.ts +23 -1
- package/src/store/fetcherStore.ts +3 -3
- package/src/users/reducers.test.ts +0 -3
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -134
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
- package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
- package/dist/_chunks-es/telemetryManager.js +0 -93
- package/dist/_chunks-es/telemetryManager.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/src/applications/userApplications.test.ts +0 -126
- package/src/applications/userApplications.ts +0 -261
- package/src/preview/getPreviewState.test.ts +0 -137
- package/src/preview/getPreviewState.ts +0 -69
- package/src/preview/resolvePreview.test.ts +0 -64
- package/src/preview/resolvePreview.ts +0 -43
- package/src/utils/createFetcherStore.test.ts +0 -171
- package/src/utils/createFetcherStore.ts +0 -284
|
@@ -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'))
|
|
@@ -632,6 +745,171 @@ describe('revertOutgoingTransaction', () => {
|
|
|
632
745
|
const docState = newState.documentStates[draftId]
|
|
633
746
|
expect(docState?.unverifiedRevisions && docState.unverifiedRevisions['txnOut']).toBeUndefined()
|
|
634
747
|
})
|
|
748
|
+
|
|
749
|
+
it('unregisters the reverted batch subscription ids, making documents with no other subscribers evictable', () => {
|
|
750
|
+
const draftId = getDraftId(DocumentId('doc1'))
|
|
751
|
+
const pubId = getPublishedId(DocumentId('doc1'))
|
|
752
|
+
const state: SyncTransactionState = {
|
|
753
|
+
queued: [],
|
|
754
|
+
applied: [],
|
|
755
|
+
outgoing: {
|
|
756
|
+
transactionId: 'txnRevert',
|
|
757
|
+
actions: [
|
|
758
|
+
{
|
|
759
|
+
type: 'document.edit',
|
|
760
|
+
documentId: 'doc1',
|
|
761
|
+
documentType: 'book',
|
|
762
|
+
patches: [{set: {foo: 'changed'}}],
|
|
763
|
+
},
|
|
764
|
+
],
|
|
765
|
+
disableBatching: false,
|
|
766
|
+
batchedTransactionIds: ['txnRevert'],
|
|
767
|
+
outgoingActions: [],
|
|
768
|
+
outgoingMutations: [],
|
|
769
|
+
base: {},
|
|
770
|
+
working: {},
|
|
771
|
+
previous: {},
|
|
772
|
+
previousRevs: {},
|
|
773
|
+
timestamp: '2025-02-06T00:04:30.000Z',
|
|
774
|
+
},
|
|
775
|
+
grants,
|
|
776
|
+
documentStates: {
|
|
777
|
+
// only subscriber is the reverted transaction itself
|
|
778
|
+
[draftId]: {
|
|
779
|
+
id: draftId,
|
|
780
|
+
subscriptions: ['txnRevert'],
|
|
781
|
+
local: {...exampleDoc, _id: draftId, foo: 'changed', _rev: 'rev2'},
|
|
782
|
+
remote: {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'},
|
|
783
|
+
},
|
|
784
|
+
// also has a UI subscriber, so it should survive with only the
|
|
785
|
+
// transaction subscription removed
|
|
786
|
+
[pubId]: {
|
|
787
|
+
id: pubId,
|
|
788
|
+
subscriptions: ['txnRevert', 'sub-ui'],
|
|
789
|
+
local: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
|
|
790
|
+
remote: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
}
|
|
794
|
+
const newState = revertOutgoingTransaction(state)
|
|
795
|
+
expect(newState.outgoing).toBeUndefined()
|
|
796
|
+
// no subscribers remain, so the document state is evicted entirely
|
|
797
|
+
expect(newState.documentStates[draftId]).toBeUndefined()
|
|
798
|
+
// the UI subscription keeps the other document alive, but the reverted
|
|
799
|
+
// transaction id is gone from its subscriptions
|
|
800
|
+
expect(newState.documentStates[pubId]?.subscriptions).toEqual(['sub-ui'])
|
|
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
|
+
})
|
|
635
913
|
})
|
|
636
914
|
|
|
637
915
|
describe('applyRemoteDocument', () => {
|
|
@@ -1124,6 +1402,85 @@ describe('applyRemoteDocument', () => {
|
|
|
1124
1402
|
})
|
|
1125
1403
|
})
|
|
1126
1404
|
|
|
1405
|
+
describe('rebase over multi-byte text', () => {
|
|
1406
|
+
it('does not kill the pipeline when a plain edit rebases onto multi-byte text', () => {
|
|
1407
|
+
// The reported production failure: a byte-offset throw from
|
|
1408
|
+
// `@sanity/diff-match-patch` is a plain `Error`, and `createMutationApplier`
|
|
1409
|
+
// only converts to `ActionError` when `preserveOperations` is set. So for an
|
|
1410
|
+
// ordinary `editDocument` it escapes this rebase loop, reaches the rxjs
|
|
1411
|
+
// subscription, and the outgoing-actions pipeline for the whole instance
|
|
1412
|
+
// dies. Every later write is then dropped silently, for every document.
|
|
1413
|
+
const docId = getDraftId(DocumentId('doc1'))
|
|
1414
|
+
const originalDoc: SanityDocument = {
|
|
1415
|
+
...exampleDoc,
|
|
1416
|
+
_id: docId,
|
|
1417
|
+
_rev: 'rev1',
|
|
1418
|
+
title: 'abcdefg\u00f8',
|
|
1419
|
+
}
|
|
1420
|
+
const locallyEditedDoc: SanityDocument = {
|
|
1421
|
+
...originalDoc,
|
|
1422
|
+
title: 'abcdefg\u00f8!',
|
|
1423
|
+
_rev: 'txnLocal',
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const appliedTransaction: AppliedTransaction = {
|
|
1427
|
+
transactionId: 'txnLocal',
|
|
1428
|
+
actions: [
|
|
1429
|
+
{
|
|
1430
|
+
type: 'document.edit',
|
|
1431
|
+
documentId: 'doc1',
|
|
1432
|
+
documentType: 'author',
|
|
1433
|
+
patches: [{diffMatchPatch: {title: '@@ -9,1 +9,2 @@\n \u00f8\n+!\n'}}],
|
|
1434
|
+
},
|
|
1435
|
+
],
|
|
1436
|
+
previous: {[docId]: originalDoc},
|
|
1437
|
+
base: {[docId]: originalDoc},
|
|
1438
|
+
working: {[docId]: locallyEditedDoc},
|
|
1439
|
+
previousRevs: {[docId]: 'rev1'},
|
|
1440
|
+
timestamp: '2025-02-06T00:16:00.000Z',
|
|
1441
|
+
outgoingActions: [],
|
|
1442
|
+
outgoingMutations: [],
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const initialState: SyncTransactionState = {
|
|
1446
|
+
queued: [],
|
|
1447
|
+
applied: [appliedTransaction],
|
|
1448
|
+
outgoing: undefined,
|
|
1449
|
+
grants,
|
|
1450
|
+
documentStates: {
|
|
1451
|
+
[docId]: {
|
|
1452
|
+
id: docId,
|
|
1453
|
+
subscriptions: ['sub1'],
|
|
1454
|
+
local: locallyEditedDoc,
|
|
1455
|
+
remote: originalDoc,
|
|
1456
|
+
unverifiedRevisions: {},
|
|
1457
|
+
},
|
|
1458
|
+
},
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// someone else changed the same field, so the local patch is rebased onto
|
|
1462
|
+
// a base its byte offsets were not computed against
|
|
1463
|
+
const remoteDoc: SanityDocument = {
|
|
1464
|
+
...originalDoc,
|
|
1465
|
+
title: '\u00f8\u00f8\u00f8\u00f8\u00f8\u00f8',
|
|
1466
|
+
_rev: 'txnForeign',
|
|
1467
|
+
}
|
|
1468
|
+
const remote: RemoteDocument = {
|
|
1469
|
+
type: 'mutation',
|
|
1470
|
+
documentId: docId,
|
|
1471
|
+
document: remoteDoc,
|
|
1472
|
+
revision: 'txnForeign',
|
|
1473
|
+
previousRev: 'rev1',
|
|
1474
|
+
timestamp: '2025-02-06T00:17:00.000Z',
|
|
1475
|
+
mutations: [{patch: {id: docId, set: {title: '\u00f8\u00f8\u00f8\u00f8\u00f8\u00f8'}}}],
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const events = new Subject<DocumentEvent>()
|
|
1479
|
+
|
|
1480
|
+
expect(() => applyRemoteDocument(initialState, remote, events)).not.toThrow()
|
|
1481
|
+
})
|
|
1482
|
+
})
|
|
1483
|
+
|
|
1127
1484
|
describe('rebase with preserved operations', () => {
|
|
1128
1485
|
it('skips a preserved-operations transaction that fails to re-apply and emits rebase-error', () => {
|
|
1129
1486
|
const docId = getDraftId(DocumentId('doc1'))
|
package/src/document/reducers.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {insecureRandomId} from '../utils/ids'
|
|
|
8
8
|
import {isDeepEqual, omitProperty} from '../utils/object'
|
|
9
9
|
import {setCleanupTimeout} from '../utils/setCleanupTimeout'
|
|
10
10
|
import {type Action} from './actions'
|
|
11
|
-
import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
|
|
11
|
+
import {DOCUMENT_STATE_CLEAR_DELAY, UNVERIFIED_REVISION_RETENTION_TIME} from './documentConstants'
|
|
12
12
|
import {type DocumentState, type DocumentStoreState} from './documentStore'
|
|
13
13
|
import {type RemoteDocument} from './listen'
|
|
14
14
|
import {ActionError, processActions} from './processActions/processActions'
|
|
@@ -401,13 +401,34 @@ 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 {
|
|
408
414
|
if (!prev.grants) return prev
|
|
415
|
+
|
|
416
|
+
// unregister the reverted batch's transaction-held subscriptions, symmetric
|
|
417
|
+
// with `cleanupOutgoingTransaction` on ack and `removeQueuedTransaction` on
|
|
418
|
+
// apply failure. without this, a reverted document's subscription IDs are
|
|
419
|
+
// never released and it can never be evicted
|
|
420
|
+
let base = prev
|
|
421
|
+
if (prev.outgoing) {
|
|
422
|
+
const ids = getDocumentIdsFromHandleLikes(prev.outgoing.actions)
|
|
423
|
+
for (const transactionId of prev.outgoing.batchedTransactionIds) {
|
|
424
|
+
for (const documentId of ids) {
|
|
425
|
+
base = removeSubscriptionIdFromDocument(base, documentId, transactionId)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
409
430
|
let working = Object.fromEntries(
|
|
410
|
-
Object.entries(
|
|
431
|
+
Object.entries(base.documentStates).map(([documentId, documentState]) => [
|
|
411
432
|
documentId,
|
|
412
433
|
documentState?.remote,
|
|
413
434
|
]),
|
|
@@ -428,23 +449,41 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
|
|
|
428
449
|
}
|
|
429
450
|
|
|
430
451
|
return {
|
|
431
|
-
...
|
|
452
|
+
...base,
|
|
432
453
|
applied: nextApplied,
|
|
433
454
|
outgoing: undefined,
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
+
),
|
|
448
487
|
),
|
|
449
488
|
}
|
|
450
489
|
}
|
|
@@ -564,7 +603,7 @@ export function applyRemoteDocument(
|
|
|
564
603
|
|
|
565
604
|
return {
|
|
566
605
|
...prev,
|
|
567
|
-
documentStates: {
|
|
606
|
+
documentStates: evictOrphanedDocumentStates({
|
|
568
607
|
...prev.documentStates,
|
|
569
608
|
[documentId]: {
|
|
570
609
|
...prevDocState,
|
|
@@ -573,7 +612,7 @@ export function applyRemoteDocument(
|
|
|
573
612
|
local,
|
|
574
613
|
unverifiedRevisions,
|
|
575
614
|
},
|
|
576
|
-
},
|
|
615
|
+
}),
|
|
577
616
|
}
|
|
578
617
|
}
|
|
579
618
|
|
|
@@ -630,7 +669,7 @@ export function applyRemoteDocument(
|
|
|
630
669
|
return {
|
|
631
670
|
...prev,
|
|
632
671
|
applied: nextApplied,
|
|
633
|
-
documentStates: {
|
|
672
|
+
documentStates: evictOrphanedDocumentStates({
|
|
634
673
|
...prev.documentStates,
|
|
635
674
|
[documentId]: {
|
|
636
675
|
...prevDocState,
|
|
@@ -639,7 +678,7 @@ export function applyRemoteDocument(
|
|
|
639
678
|
local,
|
|
640
679
|
unverifiedRevisions,
|
|
641
680
|
},
|
|
642
|
-
},
|
|
681
|
+
}),
|
|
643
682
|
}
|
|
644
683
|
}
|
|
645
684
|
|
|
@@ -670,22 +709,81 @@ export function removeSubscriptionIdFromDocument(
|
|
|
670
709
|
subscriptionId: string,
|
|
671
710
|
): SyncTransactionState {
|
|
672
711
|
const prevDocState = prev.documentStates?.[documentId]
|
|
673
|
-
const prevSubscriptions = prevDocState?.subscriptions ?? []
|
|
674
|
-
const subscriptions = prevSubscriptions.filter((id) => id !== subscriptionId)
|
|
675
|
-
|
|
676
712
|
if (!prevDocState) return prev
|
|
677
|
-
|
|
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)) {
|
|
678
725
|
return {...prev, documentStates: omitProperty(prev.documentStates, documentId)}
|
|
679
726
|
}
|
|
727
|
+
|
|
680
728
|
return {
|
|
681
729
|
...prev,
|
|
682
730
|
documentStates: {
|
|
683
731
|
...prev.documentStates,
|
|
684
|
-
[documentId]: {...prevDocState, subscriptions
|
|
732
|
+
[documentId]: {...prevDocState, subscriptions},
|
|
685
733
|
},
|
|
686
734
|
}
|
|
687
735
|
}
|
|
688
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
|
+
|
|
689
787
|
export function manageSubscriberIds(
|
|
690
788
|
{state}: StoreContext<SyncTransactionState>,
|
|
691
789
|
handles: DocumentHandleLike[],
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {getEditingDocumentId, getEffectiveDocumentId} from './util'
|
|
4
|
+
|
|
5
|
+
const PUBLISHED = 'movie-1'
|
|
6
|
+
const DRAFT = 'drafts.movie-1'
|
|
7
|
+
const VERSION = 'versions.autumn.movie-1'
|
|
8
|
+
|
|
9
|
+
describe('getEditingDocumentId', () => {
|
|
10
|
+
it('resolves the draft by default, which is what the Studio edits', () => {
|
|
11
|
+
expect(getEditingDocumentId({documentId: PUBLISHED})).toBe(DRAFT)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("resolves the draft under the 'drafts' perspective", () => {
|
|
15
|
+
expect(getEditingDocumentId({documentId: PUBLISHED, perspective: 'drafts'})).toBe(DRAFT)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("resolves the published document under the 'published' perspective", () => {
|
|
19
|
+
expect(getEditingDocumentId({documentId: PUBLISHED, perspective: 'published'})).toBe(PUBLISHED)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('resolves the version under a release perspective', () => {
|
|
23
|
+
expect(
|
|
24
|
+
getEditingDocumentId({documentId: PUBLISHED, perspective: {releaseName: 'autumn'}}),
|
|
25
|
+
).toBe(VERSION)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('resolves the published document for live edit, which has no draft', () => {
|
|
29
|
+
expect(getEditingDocumentId({documentId: PUBLISHED, liveEdit: true})).toBe(PUBLISHED)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('is idempotent when handed an already-specific id', () => {
|
|
33
|
+
expect(getEditingDocumentId({documentId: DRAFT})).toBe(DRAFT)
|
|
34
|
+
expect(getEditingDocumentId({documentId: VERSION, perspective: 'published'})).toBe(PUBLISHED)
|
|
35
|
+
expect(getEditingDocumentId({documentId: DRAFT, perspective: {releaseName: 'autumn'}})).toBe(
|
|
36
|
+
VERSION,
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('differs from getEffectiveDocumentId precisely where it has to', () => {
|
|
41
|
+
// The canonical key collapses a draft onto its published id, which is right for
|
|
42
|
+
// storing state and wrong for anything comparing exact ids.
|
|
43
|
+
expect(getEffectiveDocumentId({documentId: DRAFT, documentType: 'movie'})).toBe(PUBLISHED)
|
|
44
|
+
expect(getEditingDocumentId({documentId: DRAFT})).toBe(DRAFT)
|
|
45
|
+
})
|
|
46
|
+
})
|