@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
@@ -1,8 +1,44 @@
1
- import {DocumentId, getPublishedId, getVersionId} from '@sanity/id-utils'
1
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
2
2
 
3
3
  import {type DocumentHandle} from '../config/sanityConfig'
4
4
  import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
5
5
 
6
+ /**
7
+ * The id of the specific document a user is editing under a given perspective:
8
+ * the draft, the published document, or a release version.
9
+ *
10
+ * Distinct from {@link getEffectiveDocumentId}, which resolves the canonical key a
11
+ * document's state is stored under and so collapses a draft onto its published id.
12
+ * This one keeps them apart, because some things compare the exact id. Presence is
13
+ * the motivating case: the Studio's field indicators match the id its form is on,
14
+ * exactly, so an app reporting the published id shows up in the Studio at document
15
+ * level while never lighting up a single field.
16
+ *
17
+ * @remarks
18
+ * Resolved from the handle alone, which is right whenever a draft or version
19
+ * exists, so for any document actually being edited. The Studio additionally falls
20
+ * back to whichever document exists, so for a published document nobody has edited
21
+ * yet its form sits on the published id where this returns the draft id. Matching
22
+ * that would mean reading document state, which presence deliberately does not
23
+ * depend on.
24
+ *
25
+ * @internal
26
+ */
27
+ export function getEditingDocumentId(
28
+ doc: Pick<DocumentHandle, 'documentId' | 'liveEdit' | 'perspective'>,
29
+ ): string {
30
+ if (doc.liveEdit) {
31
+ return getPublishedId(DocumentId(doc.documentId))
32
+ }
33
+ if (isReleasePerspective(doc.perspective)) {
34
+ return getVersionId(DocumentId(doc.documentId), doc.perspective.releaseName)
35
+ }
36
+ if (doc.perspective === 'published') {
37
+ return getPublishedId(DocumentId(doc.documentId))
38
+ }
39
+ return getDraftId(DocumentId(doc.documentId))
40
+ }
41
+
6
42
  export function getEffectiveDocumentId(doc: DocumentHandle): string {
7
43
  if (doc.liveEdit) {
8
44
  return doc.documentId
@@ -1,12 +1,12 @@
1
1
  import {type Node} from '@sanity/comlink'
2
- import {firstValueFrom, of, Subject} from 'rxjs'
2
+ import {of, Subject} from 'rxjs'
3
3
  import {describe, expect, it, type Mock, vi} from 'vitest'
4
4
 
5
5
  import {getNodeState, type NodeState} from '../comlink/node/getNodeState'
6
6
  import {type FrameMessage, type WindowMessage} from '../comlink/types'
7
7
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8
8
  import {type StateSource} from '../store/createStateSourceAction'
9
- import {getFavoritesState, resolveFavoritesState} from './favorites'
9
+ import {favorites, setFavorite} from './favorites'
10
10
 
11
11
  vi.mock('../comlink/node/getNodeState', () => ({
12
12
  getNodeState: vi.fn(),
@@ -62,8 +62,8 @@ describe('favoritesStore', () => {
62
62
  it('creates different keys for different contexts with schema name', async () => {
63
63
  setupMockStateSource()
64
64
  // Make two fetches with different document IDs
65
- await resolveFavoritesState(instance!, mockContext)
66
- await resolveFavoritesState(instance!, {
65
+ await favorites.resolveState(instance!, mockContext)
66
+ await favorites.resolveState(instance!, {
67
67
  ...mockContext,
68
68
  documentId: 'different',
69
69
  })
@@ -79,8 +79,8 @@ describe('favoritesStore', () => {
79
79
  it('creates different keys for contexts without schema name', async () => {
80
80
  setupMockStateSource()
81
81
  // Make two fetches with different document IDs
82
- await resolveFavoritesState(instance!, mockContextNoSchema)
83
- await resolveFavoritesState(instance!, {
82
+ await favorites.resolveState(instance!, mockContextNoSchema)
83
+ await favorites.resolveState(instance!, {
84
84
  ...mockContextNoSchema,
85
85
  documentId: 'different',
86
86
  })
@@ -110,7 +110,7 @@ describe('favoritesStore', () => {
110
110
  it('fetches favorite status and handles success', async () => {
111
111
  const mockResponse = {isFavorited: true}
112
112
  setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue(mockResponse)})
113
- const result = await resolveFavoritesState(instance!, mockContext)
113
+ const result = await favorites.resolveState(instance!, mockContext)
114
114
  expect(result).toEqual(mockResponse)
115
115
  expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/query', {
116
116
  document: {
@@ -128,7 +128,7 @@ describe('favoritesStore', () => {
128
128
  it('handles error and returns default response', async () => {
129
129
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
130
130
  setupMockStateSource({fetchImpl: vi.fn().mockRejectedValue(new Error('Failed to fetch'))})
131
- const result = await resolveFavoritesState(instance!, mockContext)
131
+ const result = await favorites.resolveState(instance!, mockContext)
132
132
  expect(result).toEqual({isFavorited: false})
133
133
  expect(consoleErrorSpy).toHaveBeenCalledWith(
134
134
  'Favorites service connection error',
@@ -137,25 +137,19 @@ describe('favoritesStore', () => {
137
137
  consoleErrorSpy.mockRestore()
138
138
  })
139
139
 
140
- it('shares observable between multiple subscribers and cleans up', async () => {
140
+ it('serves subsequent reads within staleTime from the cache', async () => {
141
141
  const mockResponse = {isFavorited: true}
142
142
  setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue(mockResponse)})
143
- const state = getFavoritesState(instance!, mockContext)
144
- // First subscriber
145
- const sub1 = state.subscribe()
146
- await firstValueFrom(state.observable)
143
+ const first = await favorites.resolveState(instance!, mockContext)
144
+ expect(first).toEqual(mockResponse)
147
145
  expect(mockFetch).toHaveBeenCalledTimes(1)
148
- // Second subscriber should use cached response
149
- const state2 = getFavoritesState(instance!, mockContext)
150
- const sub2 = state2.subscribe()
151
- await firstValueFrom(state2.observable)
146
+ // Second read is fresh (within staleTime) — no second fetch
147
+ const second = await favorites.resolveState(instance!, mockContext)
148
+ expect(second).toEqual(mockResponse)
152
149
  expect(mockFetch).toHaveBeenCalledTimes(1)
153
- // Cleanup
154
- sub1()
155
- sub2()
156
150
  })
157
151
 
158
- it('reuses active fetch via createFetcherStore/shareReplay when called again while pending', async () => {
152
+ it('dedupes against the in-flight fetch when called again while pending', async () => {
159
153
  vi.useFakeTimers()
160
154
  let resolveFetch: (value: {isFavorited: boolean}) => void
161
155
  const fetchPromise = new Promise<{isFavorited: boolean}>((resolve) => {
@@ -175,13 +169,15 @@ describe('favoritesStore', () => {
175
169
  } as unknown as StateSource<NodeState>
176
170
  vi.mocked(getNodeState).mockReturnValue(mockStateSource)
177
171
  // Call 1: Triggers the actual fetch
178
- const promise1 = resolveFavoritesState(instance!, mockContext)
172
+ const promise1 = favorites.resolveState(instance!, mockContext)
173
+ // Let the deferred fetch start and subscribe to the node state
174
+ await vi.advanceTimersByTimeAsync(1)
179
175
  // Simulate node becoming connected
180
176
  subject.next({node: mockNode, status: 'connected'})
181
177
  await vi.advanceTimersByTimeAsync(1)
182
178
  expect(fetchSpy).toHaveBeenCalledTimes(1)
183
- // Call 2: Should reuse the pending fetch via createFetcherStore/shareReplay
184
- const promise2 = resolveFavoritesState(instance!, mockContext)
179
+ // Call 2: Should reuse the pending fetch
180
+ const promise2 = favorites.resolveState(instance!, mockContext)
185
181
  await vi.advanceTimersByTimeAsync(1)
186
182
  expect(fetchSpy).toHaveBeenCalledTimes(1)
187
183
  // Resolve the underlying fetch
@@ -195,4 +191,48 @@ describe('favoritesStore', () => {
195
191
  vi.useRealTimers()
196
192
  })
197
193
  })
194
+
195
+ describe('setFavorite', () => {
196
+ beforeEach(() => {
197
+ vi.resetAllMocks()
198
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
199
+ setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue({success: true})})
200
+ })
201
+
202
+ afterEach(() => {
203
+ instance?.dispose()
204
+ })
205
+
206
+ it('sends the added event with schemaName and resolves to the new state', async () => {
207
+ const result = await setFavorite(instance!, {...mockContext, isFavorited: true})
208
+ expect(result.data).toEqual({isFavorited: true})
209
+ expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/mutate', {
210
+ eventType: 'added',
211
+ document: {
212
+ id: mockContext.documentId,
213
+ type: mockContext.documentType,
214
+ resource: {
215
+ id: mockContext.resourceId,
216
+ type: mockContext.resourceType,
217
+ schemaName: mockContext.schemaName,
218
+ },
219
+ },
220
+ })
221
+ })
222
+
223
+ it('sends the removed event and omits schemaName when absent', async () => {
224
+ const result = await setFavorite(instance!, {...mockContextNoSchema, isFavorited: false})
225
+ expect(result.data).toEqual({isFavorited: false})
226
+ const payload = mockFetch.mock.calls[0][1]
227
+ expect(payload.eventType).toBe('removed')
228
+ expect(payload.document.resource).not.toHaveProperty('schemaName')
229
+ })
230
+
231
+ it('rejects when the server reports failure', async () => {
232
+ setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue({success: false})})
233
+ await expect(setFavorite(instance!, {...mockContext, isFavorited: true})).rejects.toThrow(
234
+ 'Failed to update favorite status',
235
+ )
236
+ })
237
+ })
198
238
  })
@@ -5,12 +5,12 @@ import {
5
5
  SDK_NODE_NAME,
6
6
  type StudioResource,
7
7
  } from '@sanity/message-protocol'
8
- import {catchError, filter, from, map, Observable, of, shareReplay, switchMap} from 'rxjs'
8
+ import {catchError, first, from, map, type Observable, of, switchMap} from 'rxjs'
9
9
 
10
10
  import {getNodeState} from '../comlink/node/getNodeState'
11
11
  import {type DocumentHandle} from '../config/sanityConfig'
12
12
  import {type SanityInstance} from '../store/createSanityInstance'
13
- import {createFetcherStore} from '../utils/createFetcherStore'
13
+ import {defineFetcher, defineMutation} from '../store/fetcherStore'
14
14
 
15
15
  /**
16
16
  * @public
@@ -22,7 +22,7 @@ export interface FavoriteStatusResponse {
22
22
  /**
23
23
  * @public
24
24
  */
25
- interface FavoriteDocumentContext extends DocumentHandle {
25
+ export interface FavoriteDocumentContext extends DocumentHandle {
26
26
  resourceId: string
27
27
  resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
28
28
  schemaName?: string
@@ -35,12 +35,19 @@ function createFavoriteKey(context: FavoriteDocumentContext): string {
35
35
  }`
36
36
  }
37
37
 
38
- const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusResponse>({
39
- name: 'Favorites',
40
- getKey: (_instance: SanityInstance, context: FavoriteDocumentContext) => {
41
- return createFavoriteKey(context)
42
- },
43
- fetcher: (instance: SanityInstance) => {
38
+ /**
39
+ * Fetcher for a document's favorite status, read from the dashboard over
40
+ * comlink, on the shared fetcher cache.
41
+ *
42
+ * @internal
43
+ */
44
+ export const favorites = defineFetcher<[context: FavoriteDocumentContext], FavoriteStatusResponse>({
45
+ name: 'favorites',
46
+ getKey: (_instance: SanityInstance, context: FavoriteDocumentContext) =>
47
+ createFavoriteKey(context),
48
+ // Tag each entry with its key so `setFavorite` can invalidate exactly it.
49
+ tags: (_data, context) => [{type: 'favorite', id: createFavoriteKey(context)}],
50
+ fetch: (instance) => {
44
51
  return (context: FavoriteDocumentContext): Observable<FavoriteStatusResponse> => {
45
52
  const nodeStateSource = getNodeState(instance, {
46
53
  name: SDK_NODE_NAME,
@@ -59,8 +66,8 @@ const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusRe
59
66
  }
60
67
 
61
68
  return nodeStateSource.observable.pipe(
62
- filter((nodeState) => !!nodeState), // Only proceed when connected
63
- shareReplay(1),
69
+ // Wait until connected, then complete after the single fetch settles.
70
+ first((nodeState) => !!nodeState),
64
71
  switchMap((nodeState) => {
65
72
  const node = nodeState!.node
66
73
  return from(
@@ -84,19 +91,63 @@ const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusRe
84
91
  })
85
92
 
86
93
  /**
87
- * Gets a StateSource for the favorite status of a document.
88
- * @param instance - The Sanity instance.
89
- * @param context - The document context including ID, type, and resource information.
90
- * @returns A StateSource emitting `{ isFavorited: boolean }`.
94
+ * Input for {@link setFavorite}: the document context plus the desired state.
95
+ *
91
96
  * @public
92
97
  */
93
- export const getFavoritesState = favorites.getState
98
+ export type SetFavoriteInput = FavoriteDocumentContext & {
99
+ /** The favorite state to move to: `true` to favorite, `false` to unfavorite. */
100
+ isFavorited: boolean
101
+ }
94
102
 
95
103
  /**
96
- * Resolves the favorite status for a document.
97
- * @param instance - The Sanity instance.
98
- * @param context - The document context including ID, type, and resource information.
99
- * @returns A Promise resolving to `{ isFavorited: boolean }`.
100
- * @public
104
+ * Sets a document's favorite state over comlink, then invalidates the cached
105
+ * {@link favorites} status for that document so active readers reconverge on
106
+ * server truth. The write-side counterpart to {@link favorites}.
107
+ *
108
+ * @internal
101
109
  */
102
- export const resolveFavoritesState = favorites.resolveState
110
+ export const setFavorite = defineMutation<SetFavoriteInput, FavoriteStatusResponse>({
111
+ name: 'setFavorite',
112
+ mutationFn: (instance) => {
113
+ return ({isFavorited, ...context}: SetFavoriteInput): Observable<FavoriteStatusResponse> => {
114
+ const nodeStateSource = getNodeState(instance, {
115
+ name: SDK_NODE_NAME,
116
+ connectTo: SDK_CHANNEL_NAME,
117
+ })
118
+ const payload = {
119
+ eventType: isFavorited ? 'added' : 'removed',
120
+ document: {
121
+ id: context.documentId,
122
+ type: context.documentType,
123
+ resource: {
124
+ id: context.resourceId,
125
+ type: context.resourceType,
126
+ ...(context.schemaName ? {schemaName: context.schemaName} : {}),
127
+ },
128
+ },
129
+ }
130
+
131
+ return nodeStateSource.observable.pipe(
132
+ // Wait until connected, then complete after the single mutation settles.
133
+ first((nodeState) => !!nodeState),
134
+ switchMap((nodeState) => {
135
+ const node = nodeState!.node
136
+ return from(
137
+ node.fetch(
138
+ // @ts-expect-error -- getOrCreateNode should be refactored to take type arguments
139
+ 'dashboard/v1/events/favorite/mutate',
140
+ payload,
141
+ ) as Promise<{success: boolean}>,
142
+ ).pipe(
143
+ map((response) => {
144
+ if (!response.success) throw new Error('Failed to update favorite status')
145
+ return {isFavorited}
146
+ }),
147
+ )
148
+ }),
149
+ )
150
+ }
151
+ },
152
+ invalidates: (_result, input) => [{type: 'favorite', id: createFavoriteKey(input)}],
153
+ })
@@ -29,6 +29,11 @@ test('Installation — no includes: only the base shape', () => {
29
29
  expectTypeOf<Extract<keyof Installation, IncludeKeys>>().toEqualTypeOf<never>()
30
30
  })
31
31
 
32
+ test('InstallationBase — the application sub-object carries name and reference', () => {
33
+ expectTypeOf<InstallationBase['application']['name']>().toEqualTypeOf<string>()
34
+ expectTypeOf<InstallationBase['application']['reference']>().toEqualTypeOf<string>()
35
+ })
36
+
32
37
  test('Installation — each token adds its top-level field, required, others absent', () => {
33
38
  expectTypeOf<Installation<'access'>['access']>().toEqualTypeOf<InstallationAccess[]>()
34
39
  expectTypeOf<
@@ -79,6 +79,10 @@ export interface InstallationBase {
79
79
  updatedAt: string
80
80
  application: {
81
81
  title: string
82
+ /** Stable, immutable identity of the installed singleton, distinct from `slug`. */
83
+ name: string
84
+ /** Qualified, globally-unique handle of the installed singleton (e.g. `sanity/<name>`). */
85
+ reference: string
82
86
  slug: string | null
83
87
  }
84
88
  }
@@ -1,7 +1,7 @@
1
1
  import {fromUrl} from '@sanity/bifur-client'
2
2
  import {type SanityClient} from '@sanity/client'
3
- import {of, Subject} from 'rxjs'
4
- import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
3
+ import {type Observable, of, Subject} from 'rxjs'
4
+ import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
5
5
 
6
6
  import {createBifurTransport} from './bifurTransport'
7
7
  import {type PresenceLocation, type TransportEvent} from './types'
@@ -39,16 +39,20 @@ describe('createBifurTransport', () => {
39
39
  let mockBifurClient: {
40
40
  listen: Mock
41
41
  request: Mock
42
+ heartbeats: Observable<Date>
42
43
  }
43
44
  let mockSanityClient: SanityClient
44
45
  let token$: Subject<string | null>
46
+ let heartbeats$: Subject<Date>
45
47
 
46
48
  beforeEach(() => {
47
49
  vi.useFakeTimers()
48
50
  vi.clearAllMocks()
51
+ heartbeats$ = new Subject<Date>()
49
52
  mockBifurClient = {
50
53
  listen: vi.fn(() => new Subject<never>()),
51
54
  request: vi.fn(() => of(undefined)),
55
+ heartbeats: heartbeats$.asObservable(),
52
56
  }
53
57
  fromUrlMock.mockReturnValue(mockBifurClient)
54
58
 
@@ -130,6 +134,8 @@ describe('createBifurTransport', () => {
130
134
 
131
135
  const receivedEvents: TransportEvent[] = []
132
136
  incomingEvents$.subscribe((event) => receivedEvents.push(event))
137
+ // The listener is established per live connection, so bring one up first.
138
+ heartbeats$.next(new Date())
133
139
 
134
140
  incomingBifurEvents$.next({
135
141
  type: 'rollCall',
@@ -161,6 +167,8 @@ describe('createBifurTransport', () => {
161
167
 
162
168
  const receivedEvents: TransportEvent[] = []
163
169
  incomingEvents$.subscribe((event) => receivedEvents.push(event))
170
+ // The listener is established per live connection, so bring one up first.
171
+ heartbeats$.next(new Date())
164
172
 
165
173
  const locations: PresenceLocation[] = [
166
174
  {type: 'document', documentId: 'doc1', path: ['a'], lastActiveAt: new Date().toISOString()},
@@ -200,6 +208,8 @@ describe('createBifurTransport', () => {
200
208
 
201
209
  const receivedEvents: TransportEvent[] = []
202
210
  incomingEvents$.subscribe((event) => receivedEvents.push(event))
211
+ // The listener is established per live connection, so bring one up first.
212
+ heartbeats$.next(new Date())
203
213
 
204
214
  incomingBifurEvents$.next({
205
215
  type: 'disconnect',
@@ -233,6 +243,7 @@ describe('createBifurTransport', () => {
233
243
  incomingEvents$.subscribe({
234
244
  error: (err) => errors.push(err),
235
245
  })
246
+ heartbeats$.next(new Date())
236
247
 
237
248
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
249
  incomingBifurEvents$.next({type: 'unknown'} as any)
@@ -242,7 +253,148 @@ describe('createBifurTransport', () => {
242
253
  expect(errors[0].message).toContain('Got unknown presence event')
243
254
  })
244
255
 
245
- describe('dispatchMessage', () => {
256
+ describe('connections', () => {
257
+ it('emits an incrementing generation each time the socket becomes live', () => {
258
+ const [, , connections$] = createBifurTransport({
259
+ client: mockSanityClient,
260
+ token$,
261
+ sessionId: 'my-session',
262
+ })
263
+
264
+ const generations: number[] = []
265
+ connections$.subscribe((generation) => generations.push(generation))
266
+
267
+ // Several heartbeats on one live socket are a single generation.
268
+ heartbeats$.next(new Date())
269
+ heartbeats$.next(new Date())
270
+ expect(generations).toEqual([1])
271
+
272
+ // bifur-client errors the stream when the socket closes. The transport is
273
+ // expected to reconnect rather than give up for the life of the page.
274
+ const reconnected$ = new Subject<Date>()
275
+ mockBifurClient.heartbeats = reconnected$.asObservable()
276
+ heartbeats$.error(new Error('WebSocket connection error'))
277
+
278
+ // First retry is scheduled 200ms out (2 ** 1 * 100).
279
+ vi.advanceTimersByTime(200)
280
+ reconnected$.next(new Date())
281
+
282
+ expect(generations).toEqual([1, 2])
283
+ })
284
+
285
+ it('does not resubscribe before the backoff has elapsed', () => {
286
+ const [, , connections$] = createBifurTransport({
287
+ client: mockSanityClient,
288
+ token$,
289
+ sessionId: 'my-session',
290
+ })
291
+
292
+ const generations: number[] = []
293
+ connections$.subscribe((generation) => generations.push(generation))
294
+ heartbeats$.next(new Date())
295
+
296
+ const reconnected$ = new Subject<Date>()
297
+ mockBifurClient.heartbeats = reconnected$.asObservable()
298
+ heartbeats$.error(new Error('WebSocket connection error'))
299
+
300
+ vi.advanceTimersByTime(199)
301
+ reconnected$.next(new Date())
302
+ expect(generations).toEqual([1])
303
+ })
304
+ })
305
+
306
+ describe('incoming events across a reconnect', () => {
307
+ it('keeps delivering presence after the socket drops', () => {
308
+ // bifur errors every stream on the same socket close, the inbound listener
309
+ // included. If only the heartbeat stream recovers, this client re-announces
310
+ // itself to peers while permanently ceasing to see any of them.
311
+ const firstListen$ = new Subject<IncomingBifurEvent>()
312
+ const secondListen$ = new Subject<IncomingBifurEvent>()
313
+ mockBifurClient.listen.mockReturnValueOnce(firstListen$).mockReturnValueOnce(secondListen$)
314
+
315
+ const [incomingEvents$, , connections$] = createBifurTransport({
316
+ client: mockSanityClient,
317
+ token$,
318
+ sessionId: 'my-session',
319
+ })
320
+
321
+ const received: TransportEvent[] = []
322
+ incomingEvents$.subscribe({
323
+ next: (event) => received.push(event),
324
+ error: () => received.push({type: 'rollCall', userId: 'STREAM_DIED', sessionId: ''}),
325
+ })
326
+ connections$.subscribe()
327
+
328
+ heartbeats$.next(new Date())
329
+ firstListen$.next({type: 'rollCall', i: 'user-1', session: 'before'})
330
+ expect(received.map((e) => e.sessionId)).toEqual(['before'])
331
+
332
+ // Socket closes: both streams error.
333
+ const reconnectedHeartbeats$ = new Subject<Date>()
334
+ mockBifurClient.heartbeats = reconnectedHeartbeats$.asObservable()
335
+ firstListen$.error(new Error('WebSocket connection error'))
336
+ heartbeats$.error(new Error('WebSocket connection error'))
337
+
338
+ vi.advanceTimersByTime(200)
339
+ reconnectedHeartbeats$.next(new Date())
340
+
341
+ // Peers answer the roll call we send on reconnect. We have to be listening.
342
+ secondListen$.next({type: 'rollCall', i: 'user-1', session: 'after'})
343
+
344
+ expect(received.map((e) => e.sessionId)).toEqual(['before', 'after'])
345
+ })
346
+ })
347
+
348
+ describe('unload', () => {
349
+ afterEach(() => {
350
+ vi.unstubAllGlobals()
351
+ })
352
+
353
+ it('emits on beforeunload and pagehide, and removes its listeners', () => {
354
+ // These tests run in Node, so stand in a bare EventTarget for `window`.
355
+ const fakeWindow = new EventTarget()
356
+ vi.stubGlobal('window', fakeWindow)
357
+
358
+ const [, , , unload$] = createBifurTransport({
359
+ client: mockSanityClient,
360
+ token$,
361
+ sessionId: 'my-session',
362
+ })
363
+
364
+ let emissions = 0
365
+ const subscription = unload$.subscribe(() => {
366
+ emissions += 1
367
+ })
368
+
369
+ fakeWindow.dispatchEvent(new Event('beforeunload'))
370
+ expect(emissions).toBe(1)
371
+
372
+ // `pagehide` matters on its own: iOS Safari and pages entering the
373
+ // back/forward cache never fire `beforeunload`.
374
+ fakeWindow.dispatchEvent(new Event('pagehide'))
375
+ expect(emissions).toBe(2)
376
+
377
+ // The listeners belong to the subscription, so unsubscribing removes them
378
+ // instead of leaking one per transport for the life of the page.
379
+ subscription.unsubscribe()
380
+ fakeWindow.dispatchEvent(new Event('beforeunload'))
381
+ expect(emissions).toBe(2)
382
+ })
383
+
384
+ it('is inert when there is no window', () => {
385
+ const [, , , unload$] = createBifurTransport({
386
+ client: mockSanityClient,
387
+ token$,
388
+ sessionId: 'my-session',
389
+ })
390
+
391
+ let completed = false
392
+ unload$.subscribe({complete: () => (completed = true)})
393
+ expect(completed).toBe(true)
394
+ })
395
+ })
396
+
397
+ describe('dispatch', () => {
246
398
  it('sends a "rollCall" message', () => {
247
399
  const [, dispatchMessage] = createBifurTransport({
248
400
  client: mockSanityClient,