@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.
Files changed (128) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +71 -3
  3. package/dist/_exports/_internal.d.ts.map +1 -1
  4. package/dist/_exports/_internal.js +332 -24
  5. package/dist/_exports/_internal.js.map +1 -1
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-C0o1NOxW.d.ts +3165 -0
  15. package/dist/authStore-C0o1NOxW.d.ts.map +1 -0
  16. package/dist/authStore-CtE9bLHW.js +754 -0
  17. package/dist/authStore-CtE9bLHW.js.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2335
  29. package/dist/index.js +4048 -3878
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-C7yMHPUI.js +1346 -0
  36. package/dist/queryStore-C7yMHPUI.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-UIEiMtC0.js +127 -0
  41. package/dist/telemetryManager-UIEiMtC0.js.map +1 -0
  42. package/dist/version-D8RP_Q59.js +9 -0
  43. package/dist/version-D8RP_Q59.js.map +1 -0
  44. package/package.json +11 -17
  45. package/src/_exports/_internal.ts +1 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +45 -90
  49. package/src/applications/applications.test-d.ts +5 -0
  50. package/src/applications/applications.ts +5 -2
  51. package/src/auth/authMode.test.ts +0 -25
  52. package/src/auth/authMode.ts +3 -6
  53. package/src/auth/authStore.test.ts +1 -1
  54. package/src/auth/authStore.ts +0 -1
  55. package/src/comments/addonDatasetStore.test.ts +215 -0
  56. package/src/comments/addonDatasetStore.ts +249 -0
  57. package/src/comments/buildCommentThreads.test.ts +83 -0
  58. package/src/comments/buildCommentThreads.ts +54 -0
  59. package/src/comments/commentActions.test.ts +540 -0
  60. package/src/comments/commentActions.ts +555 -0
  61. package/src/comments/commentFieldPath.test.ts +40 -0
  62. package/src/comments/commentFieldPath.ts +44 -0
  63. package/src/comments/commentsConstants.ts +80 -0
  64. package/src/comments/commentsStore.test.ts +419 -0
  65. package/src/comments/commentsStore.ts +483 -0
  66. package/src/comments/normalizeComment.test.ts +116 -0
  67. package/src/comments/normalizeComment.ts +45 -0
  68. package/src/comments/observeComments.test.ts +193 -0
  69. package/src/comments/observeComments.ts +113 -0
  70. package/src/comments/reducers.test.ts +277 -0
  71. package/src/comments/reducers.ts +316 -0
  72. package/src/comments/types.ts +249 -0
  73. package/src/comments/weakenReferences.test.ts +47 -0
  74. package/src/comments/weakenReferences.ts +29 -0
  75. package/src/config/sanityConfig.ts +3 -75
  76. package/src/datasets/datasets.test.ts +44 -9
  77. package/src/datasets/datasets.ts +24 -23
  78. package/src/document/applyDocumentActions.test.ts +8 -12
  79. package/src/document/documentConstants.ts +17 -0
  80. package/src/document/documentStore.test.ts +173 -19
  81. package/src/document/documentStore.ts +36 -5
  82. package/src/document/patchOperations.test.ts +27 -0
  83. package/src/document/patchOperations.ts +10 -1
  84. package/src/document/permissions.test.ts +3 -1
  85. package/src/document/reducers.test.ts +357 -0
  86. package/src/document/reducers.ts +125 -27
  87. package/src/document/util.test.ts +46 -0
  88. package/src/document/util.ts +37 -1
  89. package/src/favorites/favorites.test.ts +64 -24
  90. package/src/favorites/favorites.ts +73 -22
  91. package/src/installations/installations.test-d.ts +5 -0
  92. package/src/installations/installations.ts +4 -0
  93. package/src/presence/bifurTransport.test.ts +155 -3
  94. package/src/presence/bifurTransport.ts +79 -12
  95. package/src/presence/paths.test.ts +70 -0
  96. package/src/presence/paths.ts +41 -0
  97. package/src/presence/presenceStore.test.ts +822 -2
  98. package/src/presence/presenceStore.ts +518 -102
  99. package/src/presence/types.ts +175 -3
  100. package/src/preview/types.ts +0 -25
  101. package/src/projection/projectionStore.test.ts +0 -8
  102. package/src/projection/types.ts +0 -8
  103. package/src/query/queryStore.ts +7 -6
  104. package/src/store/createActionBinder.test.ts +51 -29
  105. package/src/store/createActionBinder.ts +16 -62
  106. package/src/store/createSanityInstance.test.ts +0 -68
  107. package/src/store/createSanityInstance.ts +0 -63
  108. package/src/store/createStateSourceAction.ts +1 -1
  109. package/src/store/fetcherStore.test.ts +23 -1
  110. package/src/store/fetcherStore.ts +3 -3
  111. package/src/users/reducers.test.ts +0 -3
  112. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
  113. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  114. package/dist/_chunks-es/_internal.js +0 -134
  115. package/dist/_chunks-es/_internal.js.map +0 -1
  116. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
  117. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  118. package/dist/_chunks-es/telemetryManager.js +0 -93
  119. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  120. package/dist/index.d.ts.map +0 -1
  121. package/src/applications/userApplications.test.ts +0 -126
  122. package/src/applications/userApplications.ts +0 -261
  123. package/src/preview/getPreviewState.test.ts +0 -137
  124. package/src/preview/getPreviewState.ts +0 -69
  125. package/src/preview/resolvePreview.test.ts +0 -64
  126. package/src/preview/resolvePreview.ts +0 -43
  127. package/src/utils/createFetcherStore.test.ts +0 -171
  128. 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'))
@@ -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
- 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 {
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(prev.documentStates).map(([documentId, documentState]) => [
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
- ...prev,
452
+ ...base,
432
453
  applied: nextApplied,
433
454
  outgoing: undefined,
434
- documentStates: Object.fromEntries(
435
- Object.entries(prev.documentStates)
436
- .filter((e): e is [string, DocumentState] => !!e[1])
437
- .map(([documentId, {unverifiedRevisions = {}, local, ...documentState}]) => {
438
- const next: DocumentState = {
439
- ...documentState,
440
- local: documentId in working ? working[documentId] : local,
441
- unverifiedRevisions:
442
- prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
443
- ? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
444
- : unverifiedRevisions,
445
- }
446
- return [documentId, next] as const
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
- 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)) {
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: 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
+ })