@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.2

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 (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  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-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.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 -2332
  29. package/dist/index.js +4171 -4332
  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-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.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-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -0,0 +1,150 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {of} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, it} from 'vitest'
4
+
5
+ import {getClientState} from '../client/clientStore'
6
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
+ import {type StateSource} from '../store/createStateSourceAction'
8
+ import {getOrganizationsCacheKey, organizations} from './organizations'
9
+
10
+ vi.mock('../client/clientStore')
11
+
12
+ describe('organizations', () => {
13
+ let instance: SanityInstance
14
+
15
+ beforeEach(() => {
16
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
17
+ })
18
+
19
+ afterEach(() => {
20
+ instance.dispose()
21
+ })
22
+
23
+ it('calls `client.observable.request` against `/organizations` and returns the result', async () => {
24
+ const list = [{id: 'org_1'}, {id: 'org_2'}]
25
+ const request = vi.fn().mockReturnValue(of(list))
26
+
27
+ const mockClient = {
28
+ observable: {request} as unknown as SanityClient['observable'],
29
+ } as SanityClient
30
+
31
+ vi.mocked(getClientState).mockReturnValue({
32
+ observable: of(mockClient),
33
+ } as StateSource<SanityClient>)
34
+
35
+ const result = await organizations.resolveState(instance)
36
+ expect(result).toEqual(list)
37
+ expect(request).toHaveBeenCalledWith({
38
+ uri: '/organizations',
39
+ query: {
40
+ includeImplicitMemberships: 'false',
41
+ includeMembers: 'false',
42
+ includeFeatures: 'false',
43
+ },
44
+ tag: 'organizations.list',
45
+ })
46
+ })
47
+
48
+ it('serializes query params (booleans → strings) and respects flags', async () => {
49
+ const request = vi.fn().mockReturnValue(of([]))
50
+ const mockClient = {
51
+ observable: {request} as unknown as SanityClient['observable'],
52
+ } as SanityClient
53
+
54
+ vi.mocked(getClientState).mockReturnValue({
55
+ observable: of(mockClient),
56
+ } as StateSource<SanityClient>)
57
+
58
+ await organizations.resolveState(instance, {
59
+ includeMembers: true,
60
+ includeFeatures: true,
61
+ includeImplicitMemberships: true,
62
+ })
63
+
64
+ expect(request).toHaveBeenCalledWith({
65
+ uri: '/organizations',
66
+ query: {
67
+ includeImplicitMemberships: 'true',
68
+ includeMembers: 'true',
69
+ includeFeatures: 'true',
70
+ },
71
+ tag: 'organizations.list',
72
+ })
73
+ })
74
+ })
75
+
76
+ describe('organizations cache key generation', () => {
77
+ let instance: SanityInstance
78
+
79
+ beforeEach(() => {
80
+ instance = createSanityInstance({})
81
+ })
82
+
83
+ afterEach(() => {
84
+ instance.dispose()
85
+ })
86
+
87
+ it('default call excludes all segments (all flags default-false)', () => {
88
+ expect(getOrganizationsCacheKey(instance)).toBe('organizations')
89
+ })
90
+
91
+ it('treats undefined and the matching default as the same key', () => {
92
+ expect(getOrganizationsCacheKey(instance)).toBe(
93
+ getOrganizationsCacheKey(instance, {
94
+ includeMembers: false,
95
+ includeFeatures: false,
96
+ includeImplicitMemberships: false,
97
+ }),
98
+ )
99
+ })
100
+
101
+ it('explicit includeMembers: true appends :members', () => {
102
+ expect(getOrganizationsCacheKey(instance, {includeMembers: true})).toBe('organizations:members')
103
+ })
104
+
105
+ it('explicit includeFeatures: true appends :features', () => {
106
+ expect(getOrganizationsCacheKey(instance, {includeFeatures: true})).toBe(
107
+ 'organizations:features',
108
+ )
109
+ })
110
+
111
+ it('explicit includeImplicitMemberships: true appends :implicit', () => {
112
+ expect(getOrganizationsCacheKey(instance, {includeImplicitMemberships: true})).toBe(
113
+ 'organizations:implicit',
114
+ )
115
+ })
116
+
117
+ it('combines all segments in order', () => {
118
+ expect(
119
+ getOrganizationsCacheKey(instance, {
120
+ includeMembers: true,
121
+ includeFeatures: true,
122
+ includeImplicitMemberships: true,
123
+ }),
124
+ ).toBe('organizations:members:features:implicit')
125
+ })
126
+
127
+ it('produces distinct keys for each meaningful option permutation', () => {
128
+ const keys = new Set([
129
+ getOrganizationsCacheKey(instance),
130
+ getOrganizationsCacheKey(instance, {includeMembers: true}),
131
+ getOrganizationsCacheKey(instance, {includeFeatures: true}),
132
+ getOrganizationsCacheKey(instance, {includeImplicitMemberships: true}),
133
+ getOrganizationsCacheKey(instance, {includeMembers: true, includeFeatures: true}),
134
+ getOrganizationsCacheKey(instance, {
135
+ includeMembers: true,
136
+ includeImplicitMemberships: true,
137
+ }),
138
+ getOrganizationsCacheKey(instance, {
139
+ includeFeatures: true,
140
+ includeImplicitMemberships: true,
141
+ }),
142
+ getOrganizationsCacheKey(instance, {
143
+ includeMembers: true,
144
+ includeFeatures: true,
145
+ includeImplicitMemberships: true,
146
+ }),
147
+ ])
148
+ expect(keys.size).toBe(8)
149
+ })
150
+ })
@@ -0,0 +1,107 @@
1
+ import {switchMap} from 'rxjs'
2
+
3
+ import {getClientState} from '../client/clientStore'
4
+ import {
5
+ type OrganizationBase,
6
+ type OrganizationMember,
7
+ type OrganizationOptions,
8
+ } from '../organization/organization'
9
+ import {type SanityInstance} from '../store/createSanityInstance'
10
+ import {defineFetcher} from '../store/fetcherStore'
11
+ import {buildQuery} from '../utils/buildQuery'
12
+
13
+ const API_VERSION = 'v2025-02-19'
14
+
15
+ /**
16
+ * The list shape returned from `/organizations`, with `members` and/or
17
+ * `features` conditionally included based on the query options used.
18
+ * @public
19
+ */
20
+ export type Organizations<
21
+ IncludeMembers extends boolean = false,
22
+ IncludeFeatures extends boolean = false,
23
+ > = (Pick<
24
+ OrganizationBase,
25
+ | 'id'
26
+ | 'name'
27
+ | 'slug'
28
+ | 'createdAt'
29
+ | 'updatedAt'
30
+ | 'defaultRoleName'
31
+ | 'dashboardStatus'
32
+ | 'aiFeaturesStatus'
33
+ > &
34
+ // `boolean extends T` is non-distributive — true only when T is the wide
35
+ // `boolean`, in which case the field is optional. Literal `true`/`false`
36
+ // fall through to the strict branch.
37
+ (boolean extends IncludeMembers
38
+ ? {members?: OrganizationMember[]}
39
+ : IncludeMembers extends true
40
+ ? {members: OrganizationMember[]}
41
+ : unknown) &
42
+ (boolean extends IncludeFeatures
43
+ ? {features?: string[]}
44
+ : IncludeFeatures extends true
45
+ ? {features: string[]}
46
+ : unknown))[]
47
+
48
+ /** @public */
49
+ export interface OrganizationsOptions<
50
+ IncludeMembers extends boolean = false,
51
+ IncludeFeatures extends boolean = false,
52
+ > extends Omit<OrganizationOptions<IncludeMembers, IncludeFeatures>, 'organizationId'> {
53
+ /**
54
+ * When `true`, includes organisations the user has access to via
55
+ * project-level grants, not just direct organisation memberships.
56
+ */
57
+ includeImplicitMemberships?: boolean
58
+ }
59
+
60
+ function normalizeOrganizationsOptions(options?: OrganizationsOptions<boolean, boolean>) {
61
+ return {
62
+ includeImplicitMemberships: options?.includeImplicitMemberships ?? false,
63
+ includeMembers: options?.includeMembers ?? false,
64
+ includeFeatures: options?.includeFeatures ?? false,
65
+ }
66
+ }
67
+
68
+ /** @internal */
69
+ export function getOrganizationsCacheKey(
70
+ _instance: SanityInstance,
71
+ options?: OrganizationsOptions<boolean, boolean>,
72
+ ): string {
73
+ const {includeMembers, includeFeatures, includeImplicitMemberships} =
74
+ normalizeOrganizationsOptions(options)
75
+ const membersKey = includeMembers ? ':members' : ''
76
+ const featuresKey = includeFeatures ? ':features' : ''
77
+ const implicitKey = includeImplicitMemberships ? ':implicit' : ''
78
+ return `organizations${membersKey}${featuresKey}${implicitKey}`
79
+ }
80
+
81
+ /**
82
+ * Fetcher for the current user's organizations (`GET /organizations`), on the
83
+ * shared fetcher cache.
84
+ *
85
+ * @internal
86
+ */
87
+ export const organizations = defineFetcher<
88
+ [options?: OrganizationsOptions<boolean, boolean>],
89
+ Organizations<boolean, boolean>
90
+ >({
91
+ name: 'organizations',
92
+ getKey: getOrganizationsCacheKey,
93
+ fetch: (instance) => (options) =>
94
+ getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
95
+ switchMap((client) =>
96
+ client.observable.request<Organizations<boolean, boolean>>({
97
+ uri: '/organizations',
98
+ query: buildQuery(normalizeOrganizationsOptions(options)),
99
+ tag: 'organizations.list',
100
+ }),
101
+ ),
102
+ ),
103
+ tags: (data) => [
104
+ {type: 'organization', id: 'LIST'},
105
+ ...data.map((org) => ({type: 'organization', id: org.id})),
106
+ ],
107
+ })
@@ -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,22 +39,28 @@ 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()
50
+ vi.clearAllMocks()
51
+ heartbeats$ = new Subject<Date>()
48
52
  mockBifurClient = {
49
53
  listen: vi.fn(() => new Subject<never>()),
50
54
  request: vi.fn(() => of(undefined)),
55
+ heartbeats: heartbeats$.asObservable(),
51
56
  }
52
57
  fromUrlMock.mockReturnValue(mockBifurClient)
53
58
 
59
+ // Default mock is a dataset client using project hostname
54
60
  mockSanityClient = {
55
61
  config: () => ({
56
62
  dataset: 'test-dataset',
57
- url: 'http://localhost:3333',
63
+ url: 'https://test-project.api.sanity.io/v2022-06-30',
58
64
  requestTagPrefix: 'test-tag',
59
65
  }),
60
66
  withConfig: vi.fn().mockReturnThis(),
@@ -63,7 +69,7 @@ describe('createBifurTransport', () => {
63
69
  token$ = new Subject<string | null>()
64
70
  })
65
71
 
66
- it('constructs the bifur client with the correct URL', () => {
72
+ it('constructs the bifur client URL for a dataset resource', () => {
67
73
  createBifurTransport({
68
74
  client: mockSanityClient,
69
75
  token$,
@@ -71,13 +77,51 @@ describe('createBifurTransport', () => {
71
77
  })
72
78
 
73
79
  expect(fromUrlMock).toHaveBeenCalledWith(
74
- 'ws://localhost:3333/socket/test-dataset?tag=test-tag',
75
- {
76
- token$,
77
- },
80
+ 'wss://test-project.api.sanity.io/v2022-06-30/socket/test-dataset?tag=test-tag',
81
+ {token$},
82
+ )
83
+ })
84
+
85
+ it('constructs the bifur client URL for a canvas resource', () => {
86
+ const canvasClient = {
87
+ config: () => ({
88
+ resource: {type: 'canvas', id: 'canvas-123'},
89
+ url: 'https://api.sanity.io/v2022-06-30',
90
+ requestTagPrefix: 'test-tag',
91
+ }),
92
+ withConfig: vi.fn().mockReturnThis(),
93
+ } as unknown as SanityClient
94
+
95
+ createBifurTransport({
96
+ client: canvasClient,
97
+ token$,
98
+ sessionId: 'session-id-123',
99
+ })
100
+
101
+ expect(fromUrlMock).toHaveBeenCalledWith(
102
+ 'wss://api.sanity.io/v2022-06-30/socket/canvases/canvas-123?tag=test-tag',
103
+ {token$},
78
104
  )
79
105
  })
80
106
 
107
+ it('throws when no canvas resource or dataset is configured', () => {
108
+ const invalidClient = {
109
+ config: () => ({
110
+ url: 'https://api.sanity.io/v2022-06-30',
111
+ requestTagPrefix: 'test-tag',
112
+ }),
113
+ withConfig: vi.fn().mockReturnThis(),
114
+ } as unknown as SanityClient
115
+
116
+ expect(() =>
117
+ createBifurTransport({
118
+ client: invalidClient,
119
+ token$,
120
+ sessionId: 'session-id-123',
121
+ }),
122
+ ).toThrow('Unable to determine presence URL')
123
+ })
124
+
81
125
  it('handles incoming rollCall events', () => {
82
126
  const incomingBifurEvents$ = new Subject<IncomingBifurEvent>()
83
127
  mockBifurClient.listen.mockReturnValue(incomingBifurEvents$)
@@ -90,6 +134,8 @@ describe('createBifurTransport', () => {
90
134
 
91
135
  const receivedEvents: TransportEvent[] = []
92
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())
93
139
 
94
140
  incomingBifurEvents$.next({
95
141
  type: 'rollCall',
@@ -121,6 +167,8 @@ describe('createBifurTransport', () => {
121
167
 
122
168
  const receivedEvents: TransportEvent[] = []
123
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())
124
172
 
125
173
  const locations: PresenceLocation[] = [
126
174
  {type: 'document', documentId: 'doc1', path: ['a'], lastActiveAt: new Date().toISOString()},
@@ -160,6 +208,8 @@ describe('createBifurTransport', () => {
160
208
 
161
209
  const receivedEvents: TransportEvent[] = []
162
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())
163
213
 
164
214
  incomingBifurEvents$.next({
165
215
  type: 'disconnect',
@@ -193,6 +243,7 @@ describe('createBifurTransport', () => {
193
243
  incomingEvents$.subscribe({
194
244
  error: (err) => errors.push(err),
195
245
  })
246
+ heartbeats$.next(new Date())
196
247
 
197
248
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
198
249
  incomingBifurEvents$.next({type: 'unknown'} as any)
@@ -202,7 +253,148 @@ describe('createBifurTransport', () => {
202
253
  expect(errors[0].message).toContain('Got unknown presence event')
203
254
  })
204
255
 
205
- 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', () => {
206
398
  it('sends a "rollCall" message', () => {
207
399
  const [, dispatchMessage] = createBifurTransport({
208
400
  client: mockSanityClient,
@@ -1,7 +1,7 @@
1
1
  import {type BifurClient, fromUrl} from '@sanity/bifur-client'
2
2
  import {type SanityClient} from '@sanity/client'
3
- import {EMPTY, fromEvent, type Observable} from 'rxjs'
4
- import {map, share, switchMap} from 'rxjs/operators'
3
+ import {defer, EMPTY, fromEvent, merge, type Observable, timer} from 'rxjs'
4
+ import {catchError, distinctUntilChanged, map, retry, share, switchMap} from 'rxjs/operators'
5
5
 
6
6
  import {
7
7
  type BifurTransportOptions,
@@ -11,6 +11,12 @@ import {
11
11
  type TransportMessage,
12
12
  } from './types'
13
13
 
14
+ /**
15
+ * Longest gap between reconnection attempts, matching the Studio's presence
16
+ * transport so both behave the same way on a flaky connection.
17
+ */
18
+ const CONNECT_RETRY_MAX_DELAY = 1000 * 240
19
+
14
20
  type BifurStateMessage = {
15
21
  type: 'state'
16
22
  i: string
@@ -36,8 +42,25 @@ type IncomingBifurEvent = RollCallEvent | BifurStateMessage | BifurDisconnectMes
36
42
 
37
43
  function getBifurClient(client: SanityClient, token$: Observable<string | null>): BifurClient {
38
44
  const bifurVersionedClient = client.withConfig({apiVersion: '2022-06-30'})
39
- const {dataset, url: baseUrl, requestTagPrefix = 'sanity.studio'} = bifurVersionedClient.config()
40
- const url = `${baseUrl.replace(/\/+$/, '')}/socket/${dataset}`.replace(/^http/, 'ws')
45
+ const {
46
+ resource,
47
+ dataset,
48
+ url: baseUrl,
49
+ requestTagPrefix = 'sanity.sdk.presence',
50
+ } = bifurVersionedClient.config()
51
+
52
+ let resourcePath: string
53
+ if (resource?.type === 'canvas') {
54
+ resourcePath = `canvases/${resource.id}`
55
+ } else if (dataset) {
56
+ // Dataset clients use project hostname — dataset name alone is the socket path
57
+ resourcePath = dataset
58
+ } else {
59
+ throw new Error(`Unable to determine presence URL: no canvas resource or dataset configured`)
60
+ }
61
+
62
+ const url = `${baseUrl}/socket/${resourcePath}`.replace(/^http/, 'ws')
63
+
41
64
  const urlWithTag = `${url}?tag=${requestTagPrefix}`
42
65
 
43
66
  return fromUrl(urlWithTag, {token$})
@@ -74,13 +97,80 @@ const handleIncomingMessage = (event: IncomingBifurEvent): TransportEvent => {
74
97
  }
75
98
  }
76
99
 
100
+ /**
101
+ * Emits an incrementing generation number each time the socket becomes live.
102
+ *
103
+ * `@sanity/bifur-client` does not reconnect on its own — it errors the stream
104
+ * when the socket closes. Without the `retry` below, presence stops for the
105
+ * life of the page after the first dropped connection.
106
+ */
107
+ const createConnections = (bifur: BifurClient): Observable<number> => {
108
+ let generation = 0
109
+
110
+ return defer(() => {
111
+ const current = ++generation
112
+ // `heartbeats` emits as soon as the socket is open and authorized, then on
113
+ // every server heartbeat and every response. Subscribing to it is also what
114
+ // keeps the socket alive between requests.
115
+ return bifur.heartbeats.pipe(map(() => current))
116
+ }).pipe(
117
+ retry({
118
+ delay: (_error, retryCount) =>
119
+ timer(Math.min(CONNECT_RETRY_MAX_DELAY, 2 ** retryCount * 100)),
120
+ resetOnSuccess: true,
121
+ }),
122
+ // After `retry` so that one operator instance spans reconnects, making each
123
+ // live socket yield exactly one emission.
124
+ distinctUntilChanged(),
125
+ share(),
126
+ )
127
+ }
128
+
129
+ /**
130
+ * Presence events from everyone else in the room, re-established on every live
131
+ * connection.
132
+ *
133
+ * A socket close errors this stream just as it errors the heartbeats, so without
134
+ * re-subscribing the client would keep announcing itself after a reconnect while
135
+ * never hearing from anyone again. Driving it from `connections$` rather than
136
+ * giving it its own `retry` keeps a single reconnect loop, instead of two
137
+ * independent backoffs racing over one refcounted socket.
138
+ */
139
+ const createIncomingEvents = (
140
+ bifur: BifurClient,
141
+ connections$: Observable<number>,
142
+ ): Observable<TransportEvent> =>
143
+ connections$.pipe(
144
+ switchMap(() =>
145
+ bifur.listen<IncomingBifurEvent>('presence').pipe(
146
+ // Let a dead listener go quietly. The next connection replaces it, and
147
+ // surfacing the error here would tear down the reconnect loop with it.
148
+ catchError(() => EMPTY),
149
+ ),
150
+ ),
151
+ map(handleIncomingMessage),
152
+ share(),
153
+ )
154
+
155
+ /**
156
+ * Emits when the page is going away. `pagehide` is needed alongside
157
+ * `beforeunload` because iOS Safari and pages entering the back/forward cache
158
+ * never fire `beforeunload`.
159
+ */
160
+ const createUnload = (): Observable<void> => {
161
+ if (typeof window === 'undefined') return EMPTY
162
+
163
+ return merge(fromEvent(window, 'beforeunload'), fromEvent(window, 'pagehide')).pipe(
164
+ map(() => undefined),
165
+ )
166
+ }
167
+
77
168
  export const createBifurTransport = (options: BifurTransportOptions): PresenceTransport => {
78
169
  const {client, token$, sessionId} = options
79
170
  const bifur = getBifurClient(client, token$)
80
171
 
81
- const incomingEvents$: Observable<TransportEvent> = bifur
82
- .listen<IncomingBifurEvent>('presence')
83
- .pipe(map(handleIncomingMessage))
172
+ const connections$ = createConnections(bifur)
173
+ const incomingEvents$ = createIncomingEvents(bifur, connections$)
84
174
 
85
175
  const dispatchMessage = (message: TransportMessage): Observable<void> => {
86
176
  switch (message.type) {
@@ -98,11 +188,5 @@ export const createBifurTransport = (options: BifurTransportOptions): PresenceTr
98
188
  }
99
189
  }
100
190
 
101
- if (typeof window !== 'undefined') {
102
- fromEvent(window, 'beforeunload')
103
- .pipe(switchMap(() => dispatchMessage({type: 'disconnect'})))
104
- .subscribe()
105
- }
106
-
107
- return [incomingEvents$.pipe(share()), dispatchMessage]
191
+ return [incomingEvents$, dispatchMessage, connections$, createUnload()]
108
192
  }