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

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 (197) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/_internal.js +134 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  7. package/dist/_chunks-es/telemetryManager.js +93 -0
  8. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  9. package/dist/_exports/_internal.d.ts +95 -0
  10. package/dist/_exports/_internal.d.ts.map +1 -0
  11. package/dist/_exports/_internal.js +24 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +1079 -1076
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1967 -2302
  16. package/dist/index.js.map +1 -1
  17. package/package.json +48 -47
  18. package/src/_exports/_internal.ts +26 -0
  19. package/src/_exports/index.ts +120 -11
  20. package/src/access/checkPermissions.test.ts +55 -0
  21. package/src/access/checkPermissions.ts +54 -0
  22. package/src/agent/agentActions.test.ts +16 -60
  23. package/src/agent/agentActions.ts +25 -38
  24. package/src/applications/applications.test-d.ts +83 -0
  25. package/src/applications/applications.test.ts +130 -0
  26. package/src/applications/applications.ts +364 -0
  27. package/src/applications/userApplications.test.ts +126 -0
  28. package/src/applications/userApplications.ts +261 -0
  29. package/src/auth/authLogger.ts +30 -0
  30. package/src/auth/authMode.test.ts +25 -0
  31. package/src/auth/authMode.ts +6 -3
  32. package/src/auth/authStore.test.ts +194 -13
  33. package/src/auth/authStore.ts +59 -26
  34. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  35. package/src/auth/getOrganizationVerificationState.ts +4 -4
  36. package/src/auth/handleAuthCallback.test.ts +35 -1
  37. package/src/auth/handleAuthCallback.ts +25 -6
  38. package/src/auth/logout.test.ts +75 -2
  39. package/src/auth/logout.ts +23 -4
  40. package/src/auth/refreshStampedToken.test.ts +219 -469
  41. package/src/auth/refreshStampedToken.ts +79 -195
  42. package/src/auth/studioAuth.ts +1 -1
  43. package/src/auth/studioModeAuth.ts +2 -1
  44. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  45. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  46. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  47. package/src/client/clientStore.test.ts +62 -37
  48. package/src/client/clientStore.ts +45 -75
  49. package/src/client/liveEvents.test.ts +151 -0
  50. package/src/client/liveEvents.ts +107 -0
  51. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  52. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  53. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  54. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  55. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  56. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  57. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  58. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  59. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  60. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  61. package/src/comlink/node/getNodeState.test.ts +1 -1
  62. package/src/comlink/node/getNodeState.ts +2 -1
  63. package/src/config/__tests__/handles.test.ts +18 -12
  64. package/src/config/handles.ts +25 -7
  65. package/src/config/sanityConfig.ts +109 -79
  66. package/src/datasets/datasets.test.ts +2 -2
  67. package/src/datasets/datasets.ts +10 -4
  68. package/src/document/actions.test.ts +115 -33
  69. package/src/document/actions.ts +212 -15
  70. package/src/document/applyDocumentActions.test.ts +38 -12
  71. package/src/document/applyDocumentActions.ts +30 -9
  72. package/src/document/documentConstants.ts +7 -0
  73. package/src/document/documentStore.concurrency.test.ts +1306 -0
  74. package/src/document/documentStore.test.ts +673 -278
  75. package/src/document/documentStore.ts +162 -71
  76. package/src/document/events.test.ts +57 -2
  77. package/src/document/events.ts +82 -26
  78. package/src/document/listen.test.ts +56 -0
  79. package/src/document/listen.ts +74 -21
  80. package/src/document/listenerEventOperators.test.ts +311 -0
  81. package/src/document/listenerEventOperators.ts +217 -0
  82. package/src/document/patchOperations.test.ts +49 -2
  83. package/src/document/patchOperations.ts +50 -0
  84. package/src/document/permissions.test.ts +105 -60
  85. package/src/document/permissions.ts +44 -19
  86. package/src/document/processActions/create.ts +138 -0
  87. package/src/document/processActions/delete.ts +100 -0
  88. package/src/document/processActions/discard.ts +63 -0
  89. package/src/document/processActions/edit.ts +154 -0
  90. package/src/document/processActions/processActions.ts +218 -0
  91. package/src/document/processActions/publish.ts +120 -0
  92. package/src/document/processActions/releaseArchive.ts +77 -0
  93. package/src/document/processActions/releaseCreate.ts +59 -0
  94. package/src/document/processActions/releaseDelete.ts +65 -0
  95. package/src/document/processActions/releaseEdit.ts +37 -0
  96. package/src/document/processActions/releasePublish.ts +45 -0
  97. package/src/document/processActions/releaseSchedule.ts +87 -0
  98. package/src/document/processActions/releaseUtil.ts +31 -0
  99. package/src/document/processActions/shared.ts +308 -0
  100. package/src/document/processActions/unpublish.ts +85 -0
  101. package/src/document/processActions.test.ts +1129 -121
  102. package/src/document/reducers.test.ts +639 -64
  103. package/src/document/reducers.ts +187 -48
  104. package/src/document/resourceRules.test.ts +178 -0
  105. package/src/document/resourceRules.ts +117 -0
  106. package/src/document/sharedListener.test.ts +16 -14
  107. package/src/document/sharedListener.ts +24 -8
  108. package/src/document/util.ts +14 -0
  109. package/src/favorites/favorites.test.ts +5 -6
  110. package/src/installations/installations.test-d.ts +68 -0
  111. package/src/installations/installations.test.ts +62 -0
  112. package/src/installations/installations.ts +202 -0
  113. package/src/organization/organization.test-d.ts +36 -0
  114. package/src/organization/organization.test.ts +138 -0
  115. package/src/organization/organization.ts +138 -0
  116. package/src/organizations/organizations.test-d.ts +56 -0
  117. package/src/organizations/organizations.test.ts +150 -0
  118. package/src/organizations/organizations.ts +107 -0
  119. package/src/presence/bifurTransport.test.ts +46 -6
  120. package/src/presence/bifurTransport.ts +19 -2
  121. package/src/presence/presenceStore.test.ts +94 -23
  122. package/src/presence/presenceStore.ts +85 -29
  123. package/src/preview/getPreviewState.test.ts +137 -0
  124. package/src/preview/getPreviewState.ts +69 -0
  125. package/src/preview/previewProjectionUtils.test.ts +9 -22
  126. package/src/preview/previewProjectionUtils.ts +7 -3
  127. package/src/preview/resolvePreview.test.ts +64 -0
  128. package/src/preview/resolvePreview.ts +43 -0
  129. package/src/preview/types.ts +10 -0
  130. package/src/project/project.test-d.ts +37 -0
  131. package/src/project/project.test.ts +157 -12
  132. package/src/project/project.ts +138 -25
  133. package/src/projection/getProjectionState.test.ts +18 -20
  134. package/src/projection/getProjectionState.ts +23 -3
  135. package/src/projection/projectionQuery.ts +5 -5
  136. package/src/projection/projectionStore.test.ts +1 -1
  137. package/src/projection/resolveProjection.test.ts +1 -2
  138. package/src/projection/resolveProjection.ts +16 -0
  139. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  140. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  141. package/src/projection/types.ts +8 -0
  142. package/src/projects/projects.test-d.ts +32 -0
  143. package/src/projects/projects.test.ts +108 -42
  144. package/src/projects/projects.ts +63 -27
  145. package/src/query/queryStore.test.ts +259 -123
  146. package/src/query/queryStore.ts +78 -63
  147. package/src/query/reducers.test.ts +0 -42
  148. package/src/query/reducers.ts +2 -18
  149. package/src/releases/getPerspectiveState.test.ts +17 -23
  150. package/src/releases/getPerspectiveState.ts +8 -20
  151. package/src/releases/observeReleases.test.ts +127 -0
  152. package/src/releases/observeReleases.ts +102 -0
  153. package/src/releases/releasesStore.test.ts +100 -53
  154. package/src/releases/releasesStore.ts +69 -44
  155. package/src/releases/utils/sortReleases.test.ts +38 -2
  156. package/src/releases/utils/sortReleases.ts +15 -10
  157. package/src/store/createActionBinder.test.ts +87 -90
  158. package/src/store/createActionBinder.ts +79 -25
  159. package/src/store/createSanityInstance.test.ts +83 -13
  160. package/src/store/createSanityInstance.ts +77 -2
  161. package/src/store/createStateSourceAction.test.ts +64 -2
  162. package/src/store/createStateSourceAction.ts +39 -44
  163. package/src/store/createStoreInstance.test.ts +4 -2
  164. package/src/store/fetcherStore.test-d.ts +121 -0
  165. package/src/store/fetcherStore.test.ts +299 -0
  166. package/src/store/fetcherStore.ts +801 -0
  167. package/src/telemetry/environment.test.ts +119 -0
  168. package/src/telemetry/environment.ts +92 -0
  169. package/src/telemetry/events.ts +42 -0
  170. package/src/telemetry/initTelemetry.test.ts +432 -0
  171. package/src/telemetry/initTelemetry.ts +218 -0
  172. package/src/telemetry/telemetryManager.test.ts +327 -0
  173. package/src/telemetry/telemetryManager.ts +199 -0
  174. package/src/users/reducers.test.ts +6 -1
  175. package/src/users/reducers.ts +5 -6
  176. package/src/users/types.ts +4 -4
  177. package/src/users/usersStore.test.ts +16 -12
  178. package/src/users/usersStore.ts +5 -1
  179. package/src/utils/buildQuery.test.ts +14 -0
  180. package/src/utils/buildQuery.ts +17 -0
  181. package/src/utils/createFetcherStore.test.ts +7 -5
  182. package/src/utils/createFetcherStore.ts +8 -5
  183. package/src/utils/getStagingApiHost.test.ts +21 -0
  184. package/src/utils/getStagingApiHost.ts +14 -0
  185. package/src/utils/ids.test.ts +1 -29
  186. package/src/utils/ids.ts +0 -10
  187. package/src/utils/includeShape.ts +36 -0
  188. package/src/utils/isImportError.test.ts +72 -0
  189. package/src/utils/isImportError.ts +34 -0
  190. package/src/utils/logger.test.ts +12 -0
  191. package/src/utils/logger.ts +8 -3
  192. package/src/utils/object.test.ts +95 -0
  193. package/src/utils/object.ts +142 -0
  194. package/src/utils/setCleanupTimeout.ts +37 -0
  195. package/src/document/processActions.ts +0 -707
  196. package/src/utils/defineIntent.test.ts +0 -477
  197. package/src/utils/defineIntent.ts +0 -244
@@ -0,0 +1,119 @@
1
+ import {afterEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {getTelemetryEnvironment, isTelemetryEnabled} from './environment'
4
+
5
+ describe('getTelemetryEnvironment', () => {
6
+ afterEach(() => {
7
+ vi.unstubAllEnvs()
8
+ vi.unstubAllGlobals()
9
+ })
10
+
11
+ describe('browser', () => {
12
+ it('returns "development" on localhost', () => {
13
+ vi.stubGlobal('window', {location: {hostname: 'localhost'}})
14
+ expect(getTelemetryEnvironment()).toBe('development')
15
+ })
16
+
17
+ it('returns "development" on 127.0.0.1', () => {
18
+ vi.stubGlobal('window', {location: {hostname: '127.0.0.1'}})
19
+ expect(getTelemetryEnvironment()).toBe('development')
20
+ })
21
+
22
+ it('returns "production" on *.sanity.studio', () => {
23
+ vi.stubGlobal('window', {location: {hostname: 'myapp.sanity.studio'}})
24
+ expect(getTelemetryEnvironment()).toBe('production')
25
+ })
26
+
27
+ it('returns "production" on www.sanity.io (dashboard)', () => {
28
+ vi.stubGlobal('window', {location: {hostname: 'www.sanity.io'}})
29
+ expect(getTelemetryEnvironment()).toBe('production')
30
+ })
31
+
32
+ it('returns null on *.sanity.work (staging is intentionally not allowlisted)', () => {
33
+ vi.stubGlobal('window', {location: {hostname: 'www.sanity.work'}})
34
+ expect(getTelemetryEnvironment()).toBeNull()
35
+ })
36
+
37
+ it('returns null on *.sanity.dev (preview hosts are intentionally not allowlisted)', () => {
38
+ vi.stubGlobal('window', {location: {hostname: 'preview-123.sanity.dev'}})
39
+ expect(getTelemetryEnvironment()).toBeNull()
40
+ })
41
+
42
+ it('is case-insensitive on hostname', () => {
43
+ vi.stubGlobal('window', {location: {hostname: 'MyApp.Sanity.Studio'}})
44
+ expect(getTelemetryEnvironment()).toBe('production')
45
+ })
46
+
47
+ it('returns null on a customer-controlled domain', () => {
48
+ vi.stubGlobal('window', {location: {hostname: 'myapp.customer.com'}})
49
+ expect(getTelemetryEnvironment()).toBeNull()
50
+ })
51
+
52
+ it('returns null on a lookalike subdomain (suffix match requires a leading dot)', () => {
53
+ // `evilsanity.studio` ends in `sanity.studio` but not `.sanity.studio`,
54
+ // so the suffix check rejects it.
55
+ vi.stubGlobal('window', {location: {hostname: 'evilsanity.studio'}})
56
+ expect(getTelemetryEnvironment()).toBeNull()
57
+ })
58
+
59
+ it('returns null on the bare apex hostname (sanity.studio with no subdomain)', () => {
60
+ // The allowlist intentionally only matches subdomains (the leading
61
+ // `.` in `.sanity.studio` means the apex `sanity.studio` is excluded).
62
+ vi.stubGlobal('window', {location: {hostname: 'sanity.studio'}})
63
+ expect(getTelemetryEnvironment()).toBeNull()
64
+ })
65
+
66
+ it('returns null when hostname is missing', () => {
67
+ vi.stubGlobal('window', {location: {}})
68
+ expect(getTelemetryEnvironment()).toBeNull()
69
+ })
70
+
71
+ it('returns "development" on localhost even when NODE_ENV is production', () => {
72
+ vi.stubEnv('NODE_ENV', 'production')
73
+ vi.stubGlobal('window', {location: {hostname: 'localhost'}})
74
+ expect(getTelemetryEnvironment()).toBe('development')
75
+ })
76
+ })
77
+
78
+ describe('node', () => {
79
+ it('returns "development" when NODE_ENV=development and no window', () => {
80
+ vi.stubEnv('NODE_ENV', 'development')
81
+ vi.stubGlobal('window', undefined)
82
+ expect(getTelemetryEnvironment()).toBe('development')
83
+ })
84
+
85
+ it('returns null when NODE_ENV=production and no window', () => {
86
+ vi.stubEnv('NODE_ENV', 'production')
87
+ vi.stubGlobal('window', undefined)
88
+ expect(getTelemetryEnvironment()).toBeNull()
89
+ })
90
+
91
+ it('returns null when NODE_ENV=test and no window', () => {
92
+ vi.stubEnv('NODE_ENV', 'test')
93
+ vi.stubGlobal('window', undefined)
94
+ expect(getTelemetryEnvironment()).toBeNull()
95
+ })
96
+ })
97
+ })
98
+
99
+ describe('isTelemetryEnabled', () => {
100
+ afterEach(() => {
101
+ vi.unstubAllEnvs()
102
+ vi.unstubAllGlobals()
103
+ })
104
+
105
+ it('returns true on a Sanity-controlled domain', () => {
106
+ vi.stubGlobal('window', {location: {hostname: 'app.sanity.studio'}})
107
+ expect(isTelemetryEnabled()).toBe(true)
108
+ })
109
+
110
+ it('returns true on localhost', () => {
111
+ vi.stubGlobal('window', {location: {hostname: 'localhost'}})
112
+ expect(isTelemetryEnabled()).toBe(true)
113
+ })
114
+
115
+ it('returns false on a customer domain', () => {
116
+ vi.stubGlobal('window', {location: {hostname: 'myapp.example.com'}})
117
+ expect(isTelemetryEnabled()).toBe(false)
118
+ })
119
+ })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Telemetry environment classification.
3
+ *
4
+ * - `'development'` — the SDK is running on the local developer's machine
5
+ * (`localhost` / `127.0.0.1` in the browser, or `NODE_ENV=development`
6
+ * in Node). These are the original dev-only telemetry sessions.
7
+ *
8
+ * - `'production'` — the SDK is running on a production Sanity-controlled
9
+ * domain (Studio deployments on `*.sanity.studio`, the dashboard on
10
+ * `*.sanity.io`) where end users are authenticated Sanity users with
11
+ * Populus consent records. Production telemetry is gated to this
12
+ * allowlist; SDK apps deployed to customer-controlled domains do not
13
+ * emit telemetry. Staging (`*.sanity.work`) and preview
14
+ * (`*.sanity.dev`) hosts are deliberately excluded.
15
+ *
16
+ * @internal
17
+ */
18
+ export type TelemetryEnvironment = 'development' | 'production'
19
+
20
+ /**
21
+ * Hostname suffixes that count as Sanity-controlled for the purposes of
22
+ * production telemetry. A user reaching one of these hosts in the
23
+ * browser is authenticated against Sanity and has a Populus consent
24
+ * record, so we apply the same telemetry rules as the Studio's
25
+ * `telemetry-sink`.
26
+ *
27
+ * The leading `.` is required: it ensures suffix matches hit a real
28
+ * subdomain boundary, so apex matches (`sanity.io`, `sanity.studio`)
29
+ * and lookalikes (`evilsanity.studio`) are excluded. Only production
30
+ * hosts are listed; staging (`*.sanity.work`) and preview
31
+ * (`*.sanity.dev`) hosts are excluded by omission.
32
+ *
33
+ * @internal
34
+ */
35
+ const SANITY_CONTROLLED_HOST_SUFFIXES = ['.sanity.studio', '.sanity.io'] as const
36
+
37
+ function getBrowserHostname(win: Window): string | null {
38
+ const hostname = win.location?.hostname
39
+ return typeof hostname === 'string' && hostname.length > 0 ? hostname.toLowerCase() : null
40
+ }
41
+
42
+ function isLocalHostname(hostname: string): boolean {
43
+ return hostname === 'localhost' || hostname === '127.0.0.1'
44
+ }
45
+
46
+ function isSanityControlledHostname(hostname: string): boolean {
47
+ return SANITY_CONTROLLED_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
48
+ }
49
+
50
+ /**
51
+ * Returns the telemetry environment for the current runtime, or `null`
52
+ * when telemetry should not run at all.
53
+ *
54
+ * Browser:
55
+ * - `localhost` / `127.0.0.1` → `'development'`
56
+ * - host matches the Sanity-controlled allowlist → `'production'`
57
+ * - anything else (customer domains, unknown contexts) → `null`
58
+ *
59
+ * Node (scripts, SSR, tests):
60
+ * - `NODE_ENV=development` → `'development'`
61
+ * - otherwise → `null`. Production-side server runtimes don't carry the
62
+ * browser-authenticated user/consent assumption, so we don't enable
63
+ * them under the production gate.
64
+ *
65
+ * Bracket-notation `process.env['NODE_ENV']` is used to avoid bundler
66
+ * dead-code replacement.
67
+ *
68
+ * @internal
69
+ */
70
+ export function getTelemetryEnvironment(): TelemetryEnvironment | null {
71
+ if (typeof window !== 'undefined') {
72
+ const hostname = getBrowserHostname(window)
73
+ if (!hostname) return null
74
+ if (isLocalHostname(hostname)) return 'development'
75
+ if (isSanityControlledHostname(hostname)) return 'production'
76
+ return null
77
+ }
78
+
79
+ if (typeof process !== 'undefined' && process.env?.['NODE_ENV'] === 'development') {
80
+ return 'development'
81
+ }
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * Convenience predicate for "telemetry can run in this environment".
87
+ *
88
+ * @internal
89
+ */
90
+ export function isTelemetryEnabled(): boolean {
91
+ return getTelemetryEnvironment() !== null
92
+ }
@@ -0,0 +1,42 @@
1
+ import {defineEvent} from '@sanity/telemetry'
2
+
3
+ /** @internal */
4
+ export const SDKSessionStarted = defineEvent<{
5
+ version: string
6
+ projectId: string
7
+ perspective: string
8
+ authMethod: string
9
+ }>({
10
+ name: 'SDK Session Started',
11
+ version: 1,
12
+ description: 'SDK instance created (environment is recorded in the event context)',
13
+ })
14
+
15
+ /** @internal */
16
+ export const SDKHookMounted = defineEvent<{
17
+ hookName: string
18
+ }>({
19
+ name: 'SDK Hook Mounted',
20
+ version: 1,
21
+ description: 'An SDK hook was mounted for the first time in this session',
22
+ })
23
+
24
+ /** @internal */
25
+ export const SDKSessionEnded = defineEvent<{
26
+ durationSeconds: number
27
+ hooksUsed: string[]
28
+ }>({
29
+ name: 'SDK Session Ended',
30
+ version: 1,
31
+ description: 'SDK instance disposed (environment is recorded in the event context)',
32
+ })
33
+
34
+ /** @internal */
35
+ export const SDKError = defineEvent<{
36
+ errorType: string
37
+ hookName: string
38
+ }>({
39
+ name: 'SDK Error',
40
+ version: 1,
41
+ description: 'Runtime error caught in the SDK',
42
+ })
@@ -0,0 +1,432 @@
1
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {getTokenState} from '../auth/authStore'
4
+ import {createSanityInstance} from '../store/createSanityInstance'
5
+ import {getTelemetryEnvironment} from './environment'
6
+ import {getTelemetryManager, initTelemetry, trackHookMounted} from './initTelemetry'
7
+ import {createTelemetryManager} from './telemetryManager'
8
+
9
+ vi.mock('./environment', () => ({
10
+ getTelemetryEnvironment: vi.fn(() => null),
11
+ }))
12
+
13
+ vi.mock('./telemetryManager', () => ({
14
+ createTelemetryManager: vi.fn(() => ({
15
+ checkConsent: vi.fn(() => Promise.resolve(true)),
16
+ logSessionStarted: vi.fn(),
17
+ logHookFirstUsed: vi.fn(),
18
+ logError: vi.fn(),
19
+ endSession: vi.fn(),
20
+ dispose: vi.fn(),
21
+ hooksUsed: new Set(),
22
+ })),
23
+ }))
24
+
25
+ vi.mock('../client/clientStore', () => ({
26
+ getClient: vi.fn(() => ({})),
27
+ }))
28
+
29
+ vi.mock('../auth/authStore', () => ({
30
+ getTokenState: vi.fn(() => ({
31
+ getCurrent: vi.fn(() => 'mock-token'),
32
+ observable: {subscribe: vi.fn()},
33
+ })),
34
+ }))
35
+
36
+ type TokenSubscriber = (token: string | null) => void
37
+
38
+ /**
39
+ * Mimics the real token state: a BehaviorSubject-like observable that
40
+ * remembers the latest value and lets the test push new ones. The real
41
+ * `getTokenState(instance).observable` is `shareReplay({bufferSize: 1, refCount: true})`,
42
+ * so we also model the option to emit synchronously on subscribe.
43
+ */
44
+ function createControlledTokenState(
45
+ options: {
46
+ initial?: string | null
47
+ /** If set, the observable emits this value synchronously on subscribe. */
48
+ emitOnSubscribe?: string | null
49
+ } = {},
50
+ ) {
51
+ const subscribers = new Set<TokenSubscriber>()
52
+ let current: string | null = options.initial ?? null
53
+
54
+ const tokenState = {
55
+ getCurrent: vi.fn(() => current),
56
+ subscribe: vi.fn(() => () => {}),
57
+ observable: {
58
+ subscribe: vi.fn((cb: TokenSubscriber) => {
59
+ subscribers.add(cb)
60
+ if (options.emitOnSubscribe !== undefined) {
61
+ current = options.emitOnSubscribe
62
+ cb(current)
63
+ }
64
+ return {
65
+ unsubscribe: vi.fn(() => {
66
+ subscribers.delete(cb)
67
+ }),
68
+ }
69
+ }),
70
+ },
71
+ } as unknown as ReturnType<typeof getTokenState>
72
+
73
+ return {
74
+ tokenState,
75
+ emit(token: string | null) {
76
+ current = token
77
+ for (const cb of [...subscribers]) cb(token)
78
+ },
79
+ subscriberCount: () => subscribers.size,
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Flush the microtask queue so the dynamic imports in initTelemetry
85
+ * have time to resolve before assertions run.
86
+ */
87
+ const flushPromises = () => new Promise<void>((r) => setTimeout(r, 0))
88
+
89
+ describe('initTelemetry', () => {
90
+ beforeEach(() => {
91
+ vi.clearAllMocks()
92
+ })
93
+
94
+ afterEach(() => {
95
+ vi.restoreAllMocks()
96
+ })
97
+
98
+ it('does nothing when the environment is not eligible', async () => {
99
+ vi.mocked(getTelemetryEnvironment).mockReturnValue(null)
100
+
101
+ const instance = createSanityInstance()
102
+
103
+ initTelemetry(instance, 'abc123')
104
+ await flushPromises()
105
+
106
+ expect(createTelemetryManager).not.toHaveBeenCalled()
107
+ instance.dispose()
108
+ })
109
+
110
+ it('does nothing when no projectId is provided', async () => {
111
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
112
+
113
+ const instance = createSanityInstance()
114
+ initTelemetry(instance, '')
115
+ await flushPromises()
116
+
117
+ expect(createTelemetryManager).not.toHaveBeenCalled()
118
+ instance.dispose()
119
+ })
120
+
121
+ it('initializes telemetry in development mode with a projectId', async () => {
122
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
123
+
124
+ const instance = createSanityInstance()
125
+
126
+ initTelemetry(instance, 'abc123')
127
+ await flushPromises()
128
+
129
+ expect(createTelemetryManager).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ sessionId: instance.instanceId,
132
+ projectId: 'abc123',
133
+ environment: 'development',
134
+ }),
135
+ )
136
+
137
+ const manager = vi.mocked(createTelemetryManager).mock.results[0].value
138
+ expect(manager.logSessionStarted).toHaveBeenCalledWith(
139
+ expect.objectContaining({
140
+ projectId: 'abc123',
141
+ perspective: 'published',
142
+ }),
143
+ )
144
+
145
+ instance.dispose()
146
+ })
147
+
148
+ it('initializes telemetry in production mode on a Sanity-controlled domain', async () => {
149
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('production')
150
+
151
+ const instance = createSanityInstance()
152
+
153
+ initTelemetry(instance, 'abc123')
154
+ await flushPromises()
155
+
156
+ expect(createTelemetryManager).toHaveBeenCalledWith(
157
+ expect.objectContaining({
158
+ sessionId: instance.instanceId,
159
+ projectId: 'abc123',
160
+ environment: 'production',
161
+ }),
162
+ )
163
+
164
+ instance.dispose()
165
+ })
166
+
167
+ it('registers manager in the WeakMap', async () => {
168
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
169
+
170
+ const instance = createSanityInstance()
171
+
172
+ initTelemetry(instance, 'abc123')
173
+ await flushPromises()
174
+
175
+ expect(getTelemetryManager(instance)).toBeDefined()
176
+
177
+ instance.dispose()
178
+ })
179
+
180
+ it('does not initialize if instance is already disposed', async () => {
181
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
182
+
183
+ const instance = createSanityInstance()
184
+
185
+ instance.dispose()
186
+ initTelemetry(instance, 'abc123')
187
+ await flushPromises()
188
+
189
+ const manager = vi.mocked(createTelemetryManager).mock.results[0]?.value
190
+ if (manager) {
191
+ expect(manager.logSessionStarted).not.toHaveBeenCalled()
192
+ }
193
+ })
194
+
195
+ it('calls endSession and removes manager on instance dispose', async () => {
196
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
197
+
198
+ const instance = createSanityInstance()
199
+
200
+ initTelemetry(instance, 'abc123')
201
+ await flushPromises()
202
+
203
+ const manager = vi.mocked(createTelemetryManager).mock.results[0].value
204
+ expect(getTelemetryManager(instance)).toBeDefined()
205
+
206
+ instance.dispose()
207
+
208
+ expect(manager.endSession).toHaveBeenCalled()
209
+ expect(getTelemetryManager(instance)).toBeUndefined()
210
+ })
211
+
212
+ it('skips telemetry entirely when user has not opted in', async () => {
213
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
214
+
215
+ const instance = createSanityInstance()
216
+
217
+ vi.mocked(createTelemetryManager).mockReturnValueOnce({
218
+ checkConsent: vi.fn(() => Promise.resolve(false)),
219
+ logSessionStarted: vi.fn(),
220
+ logHookFirstUsed: vi.fn(),
221
+ logError: vi.fn(),
222
+ endSession: vi.fn(),
223
+ dispose: vi.fn(),
224
+ hooksUsed: new Set(),
225
+ })
226
+
227
+ initTelemetry(instance, 'abc123')
228
+ await flushPromises()
229
+
230
+ expect(createTelemetryManager).toHaveBeenCalled()
231
+ const manager = vi.mocked(createTelemetryManager).mock.results[0].value
232
+
233
+ expect(manager.logSessionStarted).not.toHaveBeenCalled()
234
+ expect(manager.dispose).toHaveBeenCalled()
235
+ expect(manager.endSession).not.toHaveBeenCalled()
236
+ expect(getTelemetryManager(instance)).toBeUndefined()
237
+
238
+ instance.dispose()
239
+ })
240
+
241
+ it('uses perspective from config when available', async () => {
242
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
243
+
244
+ const instance = createSanityInstance({perspective: 'previewDrafts'})
245
+
246
+ initTelemetry(instance, 'abc123')
247
+ await flushPromises()
248
+
249
+ const manager = vi.mocked(createTelemetryManager).mock.results[0].value
250
+ expect(manager.logSessionStarted).toHaveBeenCalledWith(
251
+ expect.objectContaining({
252
+ perspective: 'previewDrafts',
253
+ }),
254
+ )
255
+
256
+ instance.dispose()
257
+ })
258
+
259
+ it('flushes hooks buffered before manager is ready', async () => {
260
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
261
+
262
+ const instance = createSanityInstance()
263
+
264
+ trackHookMounted(instance, 'useQuery')
265
+ trackHookMounted(instance, 'useDocument')
266
+
267
+ initTelemetry(instance, 'abc123')
268
+ await flushPromises()
269
+
270
+ const manager = vi.mocked(createTelemetryManager).mock.results[0].value
271
+ expect(manager.logHookFirstUsed).toHaveBeenCalledWith('useQuery')
272
+ expect(manager.logHookFirstUsed).toHaveBeenCalledWith('useDocument')
273
+
274
+ instance.dispose()
275
+ })
276
+
277
+ it('does not buffer hooks when the environment is not eligible', async () => {
278
+ vi.mocked(getTelemetryEnvironment).mockReturnValue(null)
279
+
280
+ const instance = createSanityInstance()
281
+
282
+ trackHookMounted(instance, 'useQuery')
283
+
284
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('production')
285
+ initTelemetry(instance, 'abc123')
286
+ await flushPromises()
287
+
288
+ const manager = vi.mocked(createTelemetryManager).mock.results[0].value
289
+ expect(manager.logHookFirstUsed).not.toHaveBeenCalled()
290
+
291
+ instance.dispose()
292
+ })
293
+
294
+ describe('auth token wait', () => {
295
+ beforeEach(() => {
296
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
297
+ })
298
+
299
+ it('defers initialization until the token observable emits', async () => {
300
+ const handle = createControlledTokenState({initial: null})
301
+ vi.mocked(getTokenState).mockReturnValue(handle.tokenState)
302
+
303
+ const instance = createSanityInstance()
304
+ initTelemetry(instance, 'abc123')
305
+ await flushPromises()
306
+
307
+ // No token yet — manager construction must not have happened.
308
+ expect(createTelemetryManager).not.toHaveBeenCalled()
309
+ expect(handle.subscriberCount()).toBe(1)
310
+
311
+ handle.emit('real-token')
312
+ await flushPromises()
313
+
314
+ expect(createTelemetryManager).toHaveBeenCalledWith(
315
+ expect.objectContaining({projectId: 'abc123', environment: 'development'}),
316
+ )
317
+ const manager = vi.mocked(createTelemetryManager).mock.results[0].value
318
+ expect(manager.logSessionStarted).toHaveBeenCalled()
319
+
320
+ instance.dispose()
321
+ })
322
+
323
+ it('ignores empty token emissions and keeps waiting', async () => {
324
+ const handle = createControlledTokenState({initial: null})
325
+ vi.mocked(getTokenState).mockReturnValue(handle.tokenState)
326
+
327
+ const instance = createSanityInstance()
328
+ initTelemetry(instance, 'abc123')
329
+ await flushPromises()
330
+
331
+ // Empty/falsy emissions should not be treated as a real token.
332
+ handle.emit(null)
333
+ handle.emit('')
334
+ await flushPromises()
335
+
336
+ expect(createTelemetryManager).not.toHaveBeenCalled()
337
+ expect(handle.subscriberCount()).toBe(1)
338
+
339
+ handle.emit('real-token')
340
+ await flushPromises()
341
+
342
+ expect(createTelemetryManager).toHaveBeenCalledTimes(1)
343
+
344
+ instance.dispose()
345
+ })
346
+
347
+ it('unsubscribes from the token observable once a token arrives', async () => {
348
+ const handle = createControlledTokenState({initial: null})
349
+ vi.mocked(getTokenState).mockReturnValue(handle.tokenState)
350
+
351
+ const instance = createSanityInstance()
352
+ initTelemetry(instance, 'abc123')
353
+ await flushPromises()
354
+
355
+ expect(handle.subscriberCount()).toBe(1)
356
+ handle.emit('real-token')
357
+ await flushPromises()
358
+
359
+ expect(handle.subscriberCount()).toBe(0)
360
+
361
+ instance.dispose()
362
+ })
363
+
364
+ it('aborts and unsubscribes when the instance is disposed during the wait', async () => {
365
+ const handle = createControlledTokenState({initial: null})
366
+ vi.mocked(getTokenState).mockReturnValue(handle.tokenState)
367
+
368
+ const instance = createSanityInstance()
369
+ initTelemetry(instance, 'abc123')
370
+ await flushPromises()
371
+
372
+ expect(handle.subscriberCount()).toBe(1)
373
+
374
+ instance.dispose()
375
+ await flushPromises()
376
+
377
+ expect(createTelemetryManager).not.toHaveBeenCalled()
378
+ expect(handle.subscriberCount()).toBe(0)
379
+ expect(getTelemetryManager(instance)).toBeUndefined()
380
+ })
381
+
382
+ it('does not re-subscribe to the token observable after a disposed-wait abort', async () => {
383
+ // Regression guard: if initInFlight isn't cleared on the dispose-abort
384
+ // branch, a follow-up initTelemetry call on the same instance would be
385
+ // silently dropped instead of bailing on the env/projectId check.
386
+ const handle = createControlledTokenState({initial: null})
387
+ vi.mocked(getTokenState).mockReturnValue(handle.tokenState)
388
+
389
+ const instance = createSanityInstance()
390
+ initTelemetry(instance, 'abc123')
391
+ await flushPromises()
392
+
393
+ instance.dispose()
394
+ await flushPromises()
395
+
396
+ // A second call against the same (disposed) instance should be a no-op
397
+ // and must not leave dangling subscriptions.
398
+ initTelemetry(instance, 'abc123')
399
+ await flushPromises()
400
+
401
+ expect(handle.subscriberCount()).toBe(0)
402
+ expect(createTelemetryManager).not.toHaveBeenCalled()
403
+ })
404
+
405
+ it('handles a synchronous token emission on subscribe without crashing', async () => {
406
+ // The real `getTokenState(instance).observable` is shareReplay({bufferSize: 1}),
407
+ // which means a subscribe call can deliver a buffered value synchronously
408
+ // before `subscribe()` itself returns. If `getCurrent()` saw `null` but the
409
+ // token landed in the buffer in the gap before `.subscribe(cb)`, the callback
410
+ // fires with a real token while `sub` is still in the TDZ. The init flow must
411
+ // not crash in that race.
412
+ const handle = createControlledTokenState({
413
+ initial: null,
414
+ emitOnSubscribe: 'sync-token',
415
+ })
416
+ vi.mocked(getTokenState).mockReturnValue(handle.tokenState)
417
+
418
+ const instance = createSanityInstance()
419
+ initTelemetry(instance, 'abc123')
420
+
421
+ // Must resolve without an unhandled rejection.
422
+ await flushPromises()
423
+
424
+ expect(createTelemetryManager).toHaveBeenCalledWith(
425
+ expect.objectContaining({projectId: 'abc123'}),
426
+ )
427
+ expect(handle.subscriberCount()).toBe(0)
428
+
429
+ instance.dispose()
430
+ })
431
+ })
432
+ })