@sanity/sdk 3.0.0-rc.2 → 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 (30) hide show
  1. package/dist/_exports/_internal.d.ts +1 -1
  2. package/dist/_exports/_internal.js +2 -2
  3. package/dist/_exports/comlink.d.ts +1 -1
  4. package/dist/{authStore-DKFX8fn7.d.ts → authStore-C0o1NOxW.d.ts} +29 -43
  5. package/dist/authStore-C0o1NOxW.d.ts.map +1 -0
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +131 -135
  8. package/dist/index.js.map +1 -1
  9. package/dist/{queryStore-DPf9sN3V.js → queryStore-C7yMHPUI.js} +2 -2
  10. package/dist/queryStore-C7yMHPUI.js.map +1 -0
  11. package/dist/{telemetryManager-Dq_qCkeI.js → telemetryManager-UIEiMtC0.js} +2 -2
  12. package/dist/{telemetryManager-Dq_qCkeI.js.map → telemetryManager-UIEiMtC0.js.map} +1 -1
  13. package/dist/{version-BChjsi4u.js → version-D8RP_Q59.js} +2 -2
  14. package/dist/{version-BChjsi4u.js.map → version-D8RP_Q59.js.map} +1 -1
  15. package/package.json +3 -3
  16. package/src/_exports/index.ts +7 -4
  17. package/src/datasets/datasets.test.ts +44 -9
  18. package/src/datasets/datasets.ts +24 -23
  19. package/src/document/documentConstants.ts +9 -0
  20. package/src/document/documentStore.test.ts +69 -0
  21. package/src/document/reducers.test.ts +224 -0
  22. package/src/document/reducers.ts +108 -25
  23. package/src/favorites/favorites.test.ts +64 -24
  24. package/src/favorites/favorites.ts +73 -22
  25. package/src/store/fetcherStore.test.ts +23 -1
  26. package/src/store/fetcherStore.ts +3 -3
  27. package/dist/authStore-DKFX8fn7.d.ts.map +0 -1
  28. package/dist/queryStore-DPf9sN3V.js.map +0 -1
  29. package/src/utils/createFetcherStore.test.ts +0 -171
  30. package/src/utils/createFetcherStore.ts +0 -284
@@ -1,33 +1,34 @@
1
+ import {type DatasetsResponse} from '@sanity/client'
1
2
  import {switchMap} from 'rxjs'
2
3
 
3
4
  import {getClientState} from '../client/clientStore'
4
5
  import {type ProjectHandle} from '../config/sanityConfig'
5
- import {createFetcherStore} from '../utils/createFetcherStore'
6
+ import {type SanityInstance} from '../store/createSanityInstance'
7
+ import {defineFetcher} from '../store/fetcherStore'
6
8
 
7
9
  const API_VERSION = 'v2025-02-19'
8
10
 
9
- /** @public */
10
- export const datasets = createFetcherStore({
11
- name: 'Datasets',
12
- getKey: (instance, options?: ProjectHandle) => {
13
- const projectId = options?.projectId ?? instance.config.projectId
14
- if (!projectId) {
15
- throw new Error('A projectId is required to use the project API.')
16
- }
17
- return projectId
18
- },
19
- fetcher: (instance) => (options?: ProjectHandle) => {
20
- return getClientState(instance, {
11
+ function resolveProjectId(instance: SanityInstance, options?: ProjectHandle): string {
12
+ const projectId = options?.projectId ?? instance.config.projectId
13
+ if (!projectId) {
14
+ throw new Error('A projectId is required to use the datasets API.')
15
+ }
16
+ return projectId
17
+ }
18
+
19
+ /**
20
+ * Fetcher for a project's datasets (`GET /datasets`), on the shared fetcher
21
+ * cache.
22
+ *
23
+ * @internal
24
+ */
25
+ export const datasets = defineFetcher<[options?: ProjectHandle], DatasetsResponse>({
26
+ name: 'datasets',
27
+ getKey: resolveProjectId,
28
+ fetch: (instance) => (options) =>
29
+ getClientState(instance, {
21
30
  apiVersion: API_VERSION,
22
- // non-null assertion is fine because we check above
23
- projectId: (options?.projectId ?? instance.config.projectId)!,
31
+ projectId: resolveProjectId(instance, options),
24
32
  useProjectHostname: true,
25
- }).observable.pipe(switchMap((client) => client.observable.datasets.list()))
26
- },
33
+ }).observable.pipe(switchMap((client) => client.observable.datasets.list())),
27
34
  })
28
-
29
- /** @public */
30
- export const getDatasetsState = datasets.getState
31
-
32
- /** @public */
33
- export const resolveDatasets = datasets.resolveState
@@ -9,6 +9,15 @@ export const DOCUMENT_STATE_CLEAR_DELAY = 1000
9
9
  export const INITIAL_OUTGOING_THROTTLE_TIME = 1000
10
10
  export const API_VERSION = 'v2025-05-06'
11
11
 
12
+ /**
13
+ * How long a submitted transaction's revision is kept around waiting to be
14
+ * verified by its listener echo. After this, the transaction is no longer
15
+ * treated as in flight and a document held onto only for that verification can
16
+ * be evicted. Sized to match the listener's own chaining deadline, after which
17
+ * an echo that has not arrived is not going to.
18
+ */
19
+ export const UNVERIFIED_REVISION_RETENTION_TIME = 30_000
20
+
12
21
  /**
13
22
  * Base delay (ms) before retrying a document listener after an `OutOfSyncError`.
14
23
  * Backoff doubles on each successive retry, capped at {@link OUT_OF_SYNC_RETRY_MAX_DELAY}.
@@ -357,6 +357,75 @@ it('emits remote-patches events distinguishing own and foreign transactions', as
357
357
  state2Unsubscribe()
358
358
  })
359
359
 
360
+ it('labels an echo that arrives after the last subscriber left as its own', async () => {
361
+ const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
362
+ const draftId = getDraftId(DocumentId(doc.documentId))
363
+ const state = getDocumentState(instance, doc)
364
+ const unsubscribe = state.subscribe()
365
+ await vi.waitUntil(() => state.getCurrent() !== undefined)
366
+ const previousRev = state.getCurrent()?._rev
367
+
368
+ // ack without emitting the listener echo, so the echo can be delivered later
369
+ // and out of band, which is the ordering the real API produces
370
+ const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
371
+ vi.mocked(client.action).mockImplementation(
372
+ async (_input, {transactionId = crypto.randomUUID()} = {}) => ({transactionId}),
373
+ )
374
+
375
+ const patchEvents: DocumentRemotePatchesEvent[] = []
376
+ const unsubscribeEvents = subscribeDocumentEvents(instance, {
377
+ resource,
378
+ eventHandler: (e) => {
379
+ if (e.type === 'remote-patches') patchEvents.push(e)
380
+ },
381
+ })
382
+
383
+ const edited = await applyDocumentActions(instance, {
384
+ actions: [editDocument(doc, {set: {title: 'edited while leaving'}})],
385
+ resource,
386
+ })
387
+
388
+ // the last subscriber leaves before the ack comes back, which used to delete
389
+ // the document state (and with it the memory of our own transaction) as soon
390
+ // as the ack released the transaction's subscription
391
+ unsubscribe()
392
+ await new Promise((resolve) => setTimeout(resolve, 50))
393
+ await edited.submitted()
394
+
395
+ const {events: listenerEvents} = (
396
+ createSharedListener as () => {
397
+ dispose: () => void
398
+ events: Subject<ListenEvent<SanityDocument>>
399
+ }
400
+ )()
401
+ listenerEvents.next({
402
+ type: 'mutation',
403
+ documentId: draftId,
404
+ eventId: `${edited.transactionId}#${draftId}`,
405
+ identity: 'example-user',
406
+ mutations: [{patch: {id: draftId, set: {title: 'edited while leaving'}}}],
407
+ timestamp: new Date().toISOString(),
408
+ transactionId: edited.transactionId,
409
+ transactionCurrentEvent: 1,
410
+ transactionTotalEvents: 1,
411
+ transition: 'update',
412
+ visibility: 'query',
413
+ previousRev,
414
+ resultRev: edited.transactionId,
415
+ } satisfies MutationEvent)
416
+ await vi.waitUntil(() => patchEvents.length > 0)
417
+
418
+ expect(patchEvents).toHaveLength(1)
419
+ expect(patchEvents[0]).toMatchObject({
420
+ documentId: draftId,
421
+ transactionId: edited.transactionId,
422
+ origin: 'local',
423
+ })
424
+
425
+ unsubscribeEvents()
426
+ vi.mocked(client.action).mockImplementation(clientActionMockImplementation)
427
+ })
428
+
360
429
  it('handles concurrent edits and resolves conflicts', async () => {
361
430
  const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
362
431
  const state1 = getDocumentState(instance1, doc)
@@ -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', () => {
@@ -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,7 +401,13 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
401
401
  }
402
402
  }
403
403
 
404
- return {...next, outgoing: undefined}
404
+ // every ack is also when a previously retained document's echo may have
405
+ // stopped being expected, so re-make that eviction decision here
406
+ return {
407
+ ...next,
408
+ outgoing: undefined,
409
+ documentStates: evictOrphanedDocumentStates(next.documentStates),
410
+ }
405
411
  }
406
412
 
407
413
  export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTransactionState {
@@ -446,20 +452,38 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
446
452
  ...base,
447
453
  applied: nextApplied,
448
454
  outgoing: undefined,
449
- documentStates: Object.fromEntries(
450
- Object.entries(base.documentStates)
451
- .filter((e): e is [string, DocumentState] => !!e[1])
452
- .map(([documentId, {unverifiedRevisions = {}, local, ...documentState}]) => {
453
- const next: DocumentState = {
454
- ...documentState,
455
- local: documentId in working ? working[documentId] : local,
456
- unverifiedRevisions:
457
- prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
458
- ? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
459
- : unverifiedRevisions,
460
- }
461
- return [documentId, next] as const
462
- }),
455
+ // discarding the outgoing transaction drops its echo-recognition memory,
456
+ // which can leave a document that was only being kept alive for that echo
457
+ // with nothing left to wait for, so sweep afterwards
458
+ documentStates: evictOrphanedDocumentStates(
459
+ Object.fromEntries(
460
+ Object.entries(base.documentStates)
461
+ .filter((e): e is [string, DocumentState] => !!e[1])
462
+ .map(([documentId, documentState]) => {
463
+ const {unverifiedRevisions = {}} = documentState
464
+ const next: DocumentState = {
465
+ ...documentState,
466
+ unverifiedRevisions:
467
+ prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
468
+ ? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
469
+ : unverifiedRevisions,
470
+ }
471
+
472
+ // a document with no subscribers that is still waiting on the echo
473
+ // of a different, already acked transaction of its own is not
474
+ // represented in `working` (only `applied` transactions are
475
+ // re-applied above), so rebuilding its `local` from `remote` would
476
+ // drop that transaction's effect. leave it for the echo to resolve
477
+ if (!next.subscriptions.length && isAwaitingOwnEcho(next)) {
478
+ return [documentId, next] as const
479
+ }
480
+
481
+ return [
482
+ documentId,
483
+ {...next, local: documentId in working ? working[documentId] : next.local},
484
+ ] as const
485
+ }),
486
+ ),
463
487
  ),
464
488
  }
465
489
  }
@@ -579,7 +603,7 @@ export function applyRemoteDocument(
579
603
 
580
604
  return {
581
605
  ...prev,
582
- documentStates: {
606
+ documentStates: evictOrphanedDocumentStates({
583
607
  ...prev.documentStates,
584
608
  [documentId]: {
585
609
  ...prevDocState,
@@ -588,7 +612,7 @@ export function applyRemoteDocument(
588
612
  local,
589
613
  unverifiedRevisions,
590
614
  },
591
- },
615
+ }),
592
616
  }
593
617
  }
594
618
 
@@ -645,7 +669,7 @@ export function applyRemoteDocument(
645
669
  return {
646
670
  ...prev,
647
671
  applied: nextApplied,
648
- documentStates: {
672
+ documentStates: evictOrphanedDocumentStates({
649
673
  ...prev.documentStates,
650
674
  [documentId]: {
651
675
  ...prevDocState,
@@ -654,7 +678,7 @@ export function applyRemoteDocument(
654
678
  local,
655
679
  unverifiedRevisions,
656
680
  },
657
- },
681
+ }),
658
682
  }
659
683
  }
660
684
 
@@ -685,22 +709,81 @@ export function removeSubscriptionIdFromDocument(
685
709
  subscriptionId: string,
686
710
  ): SyncTransactionState {
687
711
  const prevDocState = prev.documentStates?.[documentId]
688
- const prevSubscriptions = prevDocState?.subscriptions ?? []
689
- const subscriptions = prevSubscriptions.filter((id) => id !== subscriptionId)
690
-
691
712
  if (!prevDocState) return prev
692
- if (!subscriptions.length) {
713
+
714
+ const subscriptions = prevDocState.subscriptions.filter((id) => id !== subscriptionId)
715
+
716
+ // a document with no subscribers left is normally evicted below, but if a
717
+ // transaction was just submitted for it, its echo-recognition memory
718
+ // (`unverifiedRevisions`) is still waiting on the listener echo.
719
+ // `cleanupOutgoingTransaction` removes the outgoing transaction's own
720
+ // subscription right at HTTP ack, before the echo arrives, so deleting the
721
+ // state here would lose that memory and make the echo mislabel as
722
+ // `origin: 'remote'` (or, worse, get skipped outright). keep the state
723
+ // alive, unsubscribed, until the echo verifies or the wait expires
724
+ if (!subscriptions.length && !isAwaitingOwnEcho(prevDocState)) {
693
725
  return {...prev, documentStates: omitProperty(prev.documentStates, documentId)}
694
726
  }
727
+
695
728
  return {
696
729
  ...prev,
697
730
  documentStates: {
698
731
  ...prev.documentStates,
699
- [documentId]: {...prevDocState, subscriptions: subscriptions},
732
+ [documentId]: {...prevDocState, subscriptions},
700
733
  },
701
734
  }
702
735
  }
703
736
 
737
+ /**
738
+ * Whether this document still expects the listener echo of one of its own
739
+ * submitted transactions. That expectation is the only reason a document state
740
+ * with no subscribers is kept around.
741
+ *
742
+ * Revisions stop counting after `UNVERIFIED_REVISION_RETENTION_TIME` because an
743
+ * echo is not guaranteed to arrive at all: Content Lake records no transaction
744
+ * when every operation resolves to a no-op (e.g. a keyed path that no longer
745
+ * exists), while the client bumps `_rev` regardless and so tracks a revision
746
+ * for it. Without the expiry, such a document (and the listener keyed off its
747
+ * state) would be pinned for the lifetime of the store.
748
+ *
749
+ * `recentOwnTransactionIds` deliberately does not extend the wait: it is capped
750
+ * rather than drained, so honoring it would keep every edited document alive.
751
+ */
752
+ function isAwaitingOwnEcho(documentState: DocumentState): boolean {
753
+ const expiresAfter = Date.now() - UNVERIFIED_REVISION_RETENTION_TIME
754
+ return Object.values(documentState.unverifiedRevisions ?? {}).some(
755
+ (revision) => !!revision && new Date(revision.timestamp).getTime() > expiresAfter,
756
+ )
757
+ }
758
+
759
+ /**
760
+ * Removes every document state that has no subscribers left and no own
761
+ * transaction still awaiting its echo.
762
+ *
763
+ * This is a sweep rather than a decision made where the last subscriber leaves
764
+ * because `removeSubscriptionIdFromDocument` defers that eviction while an echo
765
+ * is in flight, and both halves of the condition can change without a
766
+ * subscriber going anywhere: the echo can verify, be dropped by a sync, be
767
+ * discarded by a revert, or simply never arrive. Every reducer that can settle
768
+ * an in-flight transaction sweeps, so the deferred decision is always re-made.
769
+ *
770
+ * Note this can evict the very document whose remote event is being processed,
771
+ * which synchronously unsubscribes that document's listener from inside its own
772
+ * emission (see `subscribeToSubscriptionsAndListenToDocuments`).
773
+ */
774
+ function evictOrphanedDocumentStates(
775
+ documentStates: SyncTransactionState['documentStates'],
776
+ ): SyncTransactionState['documentStates'] {
777
+ let next = documentStates
778
+ for (const [documentId, documentState] of Object.entries(documentStates)) {
779
+ if (!documentState) continue
780
+ if (documentState.subscriptions.length) continue
781
+ if (isAwaitingOwnEcho(documentState)) continue
782
+ next = omitProperty(next, documentId)
783
+ }
784
+ return next
785
+ }
786
+
704
787
  export function manageSubscriberIds(
705
788
  {state}: StoreContext<SyncTransactionState>,
706
789
  handles: DocumentHandleLike[],