@sanity/sdk 3.0.0-rc.2 → 3.1.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 (102) hide show
  1. package/dist/_exports/_internal.d.ts +25 -3
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +127 -9
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/_exports/agent.d.ts +1 -1
  6. package/dist/_exports/agent.js +1 -1
  7. package/dist/_exports/comlink.d.ts +1 -1
  8. package/dist/_exports/comlink.js +2 -3
  9. package/dist/_exports/comlink.js.map +1 -1
  10. package/dist/{authStore-DKFX8fn7.d.ts → authStore-C-OEkGaM.d.ts} +30 -44
  11. package/dist/authStore-C-OEkGaM.d.ts.map +1 -0
  12. package/dist/{authStore-CtE9bLHW.js → clientStore-gUALQ0qW.js} +269 -11
  13. package/dist/{authStore-CtE9bLHW.js.map → clientStore-gUALQ0qW.js.map} +1 -1
  14. package/dist/{createSanityInstance-DUI1bGIg.d.ts → createSanityInstance-DCePpBYW.d.ts} +2 -2
  15. package/dist/{createSanityInstance-DUI1bGIg.d.ts.map → createSanityInstance-DCePpBYW.d.ts.map} +1 -1
  16. package/dist/{getNodeState-CR3__B2D.js → getNodeState-E0hjqFOo.js} +2 -3
  17. package/dist/{getNodeState-CR3__B2D.js.map → getNodeState-E0hjqFOo.js.map} +1 -1
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +168 -176
  20. package/dist/index.js.map +1 -1
  21. package/dist/{createStateSourceAction-BOKtqFNm.js → object-BwynGPRZ.js} +87 -3
  22. package/dist/object-BwynGPRZ.js.map +1 -0
  23. package/dist/{queryStore-DPf9sN3V.js → version-C90rNWaX.js} +48 -16
  24. package/dist/version-C90rNWaX.js.map +1 -0
  25. package/package.json +4 -4
  26. package/src/_exports/_internal.ts +1 -0
  27. package/src/_exports/index.ts +7 -4
  28. package/src/access/checkPermissions.test.ts +1 -1
  29. package/src/access/checkPermissions.ts +1 -1
  30. package/src/applications/applications.test.ts +4 -4
  31. package/src/applications/applications.ts +4 -4
  32. package/src/auth/handleAuthCallback.test.ts +2 -2
  33. package/src/auth/handleAuthCallback.ts +1 -1
  34. package/src/auth/logout.test.ts +1 -1
  35. package/src/auth/logout.ts +1 -1
  36. package/src/auth/refreshStampedToken.ts +1 -1
  37. package/src/auth/studioModeAuth.test.ts +1 -1
  38. package/src/auth/studioModeAuth.ts +1 -1
  39. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -2
  40. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -1
  41. package/src/comments/addonDatasetStore.test.ts +2 -2
  42. package/src/comments/addonDatasetStore.ts +2 -2
  43. package/src/comments/commentActions.ts +6 -5
  44. package/src/comments/commentsStore.ts +4 -4
  45. package/src/datasets/datasets.test.ts +44 -9
  46. package/src/datasets/datasets.ts +24 -23
  47. package/src/document/applyDocumentActions.ts +2 -6
  48. package/src/document/documentConstants.ts +9 -0
  49. package/src/document/documentStore.concurrency.test.ts +2 -1
  50. package/src/document/documentStore.test.ts +87 -17
  51. package/src/document/documentStore.ts +5 -5
  52. package/src/document/permissions.ts +2 -1
  53. package/src/document/processActions.test.ts +2 -1
  54. package/src/document/processMutations.ts +3 -2
  55. package/src/document/reducers.test.ts +224 -0
  56. package/src/document/reducers.ts +110 -27
  57. package/src/favorites/favorites.test.ts +64 -24
  58. package/src/favorites/favorites.ts +73 -22
  59. package/src/installations/installations.test.ts +2 -2
  60. package/src/installations/installations.ts +2 -2
  61. package/src/organization/organization.test.ts +2 -2
  62. package/src/organization/organization.ts +1 -1
  63. package/src/organizations/organizations.test.ts +2 -2
  64. package/src/organizations/organizations.ts +1 -1
  65. package/src/presence/presenceStore.test.ts +15 -22
  66. package/src/presence/presenceStore.ts +3 -2
  67. package/src/project/project.test.ts +3 -3
  68. package/src/project/project.ts +1 -1
  69. package/src/projection/getProjectionState.test.ts +6 -4
  70. package/src/projection/getProjectionState.ts +2 -2
  71. package/src/projects/projects.test.ts +2 -2
  72. package/src/projects/projects.ts +1 -1
  73. package/src/query/queryStore.ts +3 -3
  74. package/src/store/createSanityInstance.test.ts +1 -1
  75. package/src/store/createSanityInstance.ts +4 -4
  76. package/src/store/createStoreInstance.test.ts +0 -5
  77. package/src/store/fetcherStore.test.ts +23 -1
  78. package/src/store/fetcherStore.ts +3 -3
  79. package/src/telemetry/telemetryManager.test.ts +1 -1
  80. package/src/telemetry/telemetryManager.ts +2 -2
  81. package/src/users/usersStore.test.ts +1 -1
  82. package/src/users/usersStore.ts +5 -5
  83. package/src/utils/ids.test.ts +40 -15
  84. package/src/utils/ids.ts +47 -2
  85. package/dist/authStore-DKFX8fn7.d.ts.map +0 -1
  86. package/dist/clientStore-BtE2-q2i.js +0 -90
  87. package/dist/clientStore-BtE2-q2i.js.map +0 -1
  88. package/dist/createStateSourceAction-BOKtqFNm.js.map +0 -1
  89. package/dist/getEnv-D5RGkQLu.js +0 -8
  90. package/dist/getEnv-D5RGkQLu.js.map +0 -1
  91. package/dist/logger-DAtJb5RQ.js +0 -173
  92. package/dist/logger-DAtJb5RQ.js.map +0 -1
  93. package/dist/object-CxXlSVg7.js +0 -83
  94. package/dist/object-CxXlSVg7.js.map +0 -1
  95. package/dist/queryStore-DPf9sN3V.js.map +0 -1
  96. package/dist/rolldown-runtime-vyAXikos.js +0 -9
  97. package/dist/telemetryManager-Dq_qCkeI.js +0 -127
  98. package/dist/telemetryManager-Dq_qCkeI.js.map +0 -1
  99. package/dist/version-BChjsi4u.js +0 -9
  100. package/dist/version-BChjsi4u.js.map +0 -1
  101. package/src/utils/createFetcherStore.test.ts +0 -171
  102. package/src/utils/createFetcherStore.ts +0 -284
@@ -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
+ })
@@ -40,7 +40,7 @@ describe('installations', () => {
40
40
  scope: 'global',
41
41
  })
42
42
  expect(request).toHaveBeenCalledWith({
43
- uri: '/installations',
43
+ url: '/installations',
44
44
  query: {organizationId: 'org1', include: 'interfaces'},
45
45
  tag: 'installations.list',
46
46
  })
@@ -54,7 +54,7 @@ describe('installations', () => {
54
54
 
55
55
  expect(result).toEqual(inst)
56
56
  expect(request).toHaveBeenCalledWith({
57
- uri: '/installations/inst1',
57
+ url: '/installations/inst1',
58
58
  query: {},
59
59
  tag: 'installations.get',
60
60
  })
@@ -160,7 +160,7 @@ export const installations = defineFetcher<
160
160
  getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
161
161
  switchMap((client) =>
162
162
  client.observable.request<InstallationsResponse<InstallationInclude>>({
163
- uri: '/installations',
163
+ url: '/installations',
164
164
  query: buildQuery({
165
165
  organizationId: options.organizationId,
166
166
  include: serializeInclude(options.include),
@@ -196,7 +196,7 @@ export const installation = defineFetcher<
196
196
  getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
197
197
  switchMap((client) =>
198
198
  client.observable.request<Installation<InstallationInclude>>({
199
- uri: `/installations/${installationId}`,
199
+ url: `/installations/${installationId}`,
200
200
  query: buildQuery({include: serializeInclude(options?.include)}),
201
201
  tag: 'installations.get',
202
202
  }),
@@ -35,7 +35,7 @@ describe('organization', () => {
35
35
  const result = await organization.resolveState(instance, {organizationId: 'org_1'})
36
36
  expect(result).toEqual(detail)
37
37
  expect(request).toHaveBeenCalledWith({
38
- uri: '/organizations/org_1',
38
+ url: '/organizations/org_1',
39
39
  query: {includeMembers: 'false', includeFeatures: 'false'},
40
40
  tag: 'organizations.get',
41
41
  })
@@ -58,7 +58,7 @@ describe('organization', () => {
58
58
  })
59
59
 
60
60
  expect(request).toHaveBeenCalledWith({
61
- uri: '/organizations/org_1',
61
+ url: '/organizations/org_1',
62
62
  query: {
63
63
  includeMembers: 'true',
64
64
  includeFeatures: 'true',
@@ -128,7 +128,7 @@ export const organization = defineFetcher<
128
128
  getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
129
129
  switchMap((client) =>
130
130
  client.observable.request<Organization<boolean, boolean>>({
131
- uri: `/organizations/${resolveOrganizationId(options)}`,
131
+ url: `/organizations/${resolveOrganizationId(options)}`,
132
132
  query: buildQuery(normalizeOrganizationOptions(options)),
133
133
  tag: 'organizations.get',
134
134
  }),
@@ -35,7 +35,7 @@ describe('organizations', () => {
35
35
  const result = await organizations.resolveState(instance)
36
36
  expect(result).toEqual(list)
37
37
  expect(request).toHaveBeenCalledWith({
38
- uri: '/organizations',
38
+ url: '/organizations',
39
39
  query: {
40
40
  includeImplicitMemberships: 'false',
41
41
  includeMembers: 'false',
@@ -62,7 +62,7 @@ describe('organizations', () => {
62
62
  })
63
63
 
64
64
  expect(request).toHaveBeenCalledWith({
65
- uri: '/organizations',
65
+ url: '/organizations',
66
66
  query: {
67
67
  includeImplicitMemberships: 'true',
68
68
  includeMembers: 'true',
@@ -94,7 +94,7 @@ export const organizations = defineFetcher<
94
94
  getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
95
95
  switchMap((client) =>
96
96
  client.observable.request<Organizations<boolean, boolean>>({
97
- uri: '/organizations',
97
+ url: '/organizations',
98
98
  query: buildQuery(normalizeOrganizationsOptions(options)),
99
99
  tag: 'organizations.list',
100
100
  }),
@@ -24,6 +24,12 @@ vi.mock('../client/clientStore')
24
24
  vi.mock('../users/usersStore')
25
25
  vi.mock('./bifurTransport')
26
26
 
27
+ function lastTransportSessionId(): string {
28
+ const call = vi.mocked(createBifurTransport).mock.calls.at(-1)
29
+ if (!call) throw new Error('createBifurTransport was not called')
30
+ return call[0].sessionId
31
+ }
32
+
27
33
  describe('presenceStore', () => {
28
34
  let instance: SanityInstance
29
35
  let mockClient: SanityClient
@@ -49,13 +55,6 @@ describe('presenceStore', () => {
49
55
  beforeEach(() => {
50
56
  vi.clearAllMocks()
51
57
 
52
- // Mock crypto.randomUUID
53
- Object.defineProperty(global, 'crypto', {
54
- value: {
55
- randomUUID: vi.fn(() => 'test-session-id'),
56
- },
57
- })
58
-
59
58
  mockClient = {
60
59
  withConfig: vi.fn().mockReturnThis(),
61
60
  observable: {
@@ -103,7 +102,7 @@ describe('presenceStore', () => {
103
102
  expect(createBifurTransport).toHaveBeenCalledWith({
104
103
  client: mockClient,
105
104
  token$: expect.any(Object),
106
- sessionId: 'test-session-id',
105
+ sessionId: expect.stringMatching(/^[a-zA-Z0-9]{16}$/),
107
106
  })
108
107
  })
109
108
 
@@ -164,7 +163,7 @@ describe('presenceStore', () => {
164
163
  mockIncomingEvents.next({
165
164
  type: 'state',
166
165
  userId: 'user-1',
167
- sessionId: 'test-session-id', // Same as our session
166
+ sessionId: lastTransportSessionId(), // Same as our session
168
167
  timestamp: '2023-01-01T12:00:00Z',
169
168
  locations: [],
170
169
  })
@@ -314,7 +313,7 @@ describe('presenceStore', () => {
314
313
  getPresence(instance, {resource: canvasResource})
315
314
 
316
315
  expect(mockClient.observable.request).toHaveBeenCalledWith({
317
- uri: '/canvases/canvas123',
316
+ url: '/canvases/canvas123',
318
317
  tag: 'canvases.get',
319
318
  })
320
319
  })
@@ -1050,7 +1049,7 @@ describe('presenceStore', () => {
1050
1049
  mockIncomingEvents.next({
1051
1050
  type: 'rollCall',
1052
1051
  userId: 'user-1',
1053
- sessionId: 'test-session-id',
1052
+ sessionId: lastTransportSessionId(),
1054
1053
  })
1055
1054
  await flush()
1056
1055
 
@@ -1139,25 +1138,19 @@ describe('presenceStore', () => {
1139
1138
  })
1140
1139
 
1141
1140
  describe('session id', () => {
1142
- afterEach(() => {
1143
- vi.unstubAllGlobals()
1144
- })
1145
-
1146
1141
  it('gives each store its own id, so nothing can collide across tabs', () => {
1147
1142
  // Ids are deliberately not persisted: a tab that inherits another's
1148
1143
  // session storage would otherwise reuse the id and the two live clients
1149
1144
  // would filter each other out as their own session.
1150
- let counter = 0
1151
- vi.stubGlobal('crypto', {randomUUID: vi.fn(() => `session-${++counter}`)})
1152
-
1153
1145
  getPresence(instance, {resource: {projectId: 'p1', dataset: 'd1'}}).subscribe(() => {})
1154
- const first = vi.mocked(createBifurTransport).mock.calls.at(-1)?.[0].sessionId
1146
+ const first = lastTransportSessionId()
1155
1147
 
1156
1148
  getPresence(instance, {resource: {projectId: 'p2', dataset: 'd2'}}).subscribe(() => {})
1157
- const second = vi.mocked(createBifurTransport).mock.calls.at(-1)?.[0].sessionId
1149
+ const second = lastTransportSessionId()
1158
1150
 
1159
- expect(first).toBe('session-1')
1160
- expect(second).toBe('session-2')
1151
+ expect(first).toMatch(/^[a-zA-Z0-9]{16}$/)
1152
+ expect(second).toMatch(/^[a-zA-Z0-9]{16}$/)
1153
+ expect(first).not.toBe(second)
1161
1154
  })
1162
1155
  })
1163
1156
  })
@@ -38,6 +38,7 @@ import {
38
38
  import {defineStore, type StoreContext} from '../store/defineStore'
39
39
  import {type SanityUser} from '../users/types'
40
40
  import {getUserState} from '../users/usersStore'
41
+ import {randomId} from '../utils/ids'
41
42
  import {createLogger} from '../utils/logger'
42
43
  import {createBifurTransport} from './bifurTransport'
43
44
  import {startsWithPath} from './paths'
@@ -198,7 +199,7 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
198
199
  // storage (`window.open`, or a same-origin iframe), making two live clients
199
200
  // filter each other out as self. Stale sessions are handled by the
200
201
  // disconnect on unload, with the expiry sweep above as the backstop.
201
- const sessionId = crypto.randomUUID()
202
+ const sessionId = randomId(16)
202
203
 
203
204
  // Dataset resources must use the project hostname so the socket URL is project-specific.
204
205
  // Canvas resources use the global API endpoint via the resource config.
@@ -411,7 +412,7 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
411
412
  subscription.add(
412
413
  globalClient.observable
413
414
  .request<{organizationId: string}>({
414
- uri: `/canvases/${resource.canvasId}`,
415
+ url: `/canvases/${resource.canvasId}`,
415
416
  tag: 'canvases.get',
416
417
  })
417
418
  .subscribe({
@@ -36,7 +36,7 @@ describe('project', () => {
36
36
  const result = await project.resolveState(instance, {projectId: 'a'})
37
37
  expect(result).toEqual(detail)
38
38
  expect(request).toHaveBeenCalledWith({
39
- uri: '/projects/a',
39
+ url: '/projects/a',
40
40
  query: {includeMembers: 'true', includeFeatures: 'true'},
41
41
  tag: 'projects.get',
42
42
  })
@@ -59,7 +59,7 @@ describe('project', () => {
59
59
  })
60
60
 
61
61
  expect(request).toHaveBeenCalledWith({
62
- uri: '/projects/a',
62
+ url: '/projects/a',
63
63
  query: {
64
64
  includeMembers: 'false',
65
65
  includeFeatures: 'false',
@@ -80,7 +80,7 @@ describe('project', () => {
80
80
 
81
81
  await project.resolveState(instance)
82
82
  expect(request).toHaveBeenCalledWith({
83
- uri: '/projects/p',
83
+ url: '/projects/p',
84
84
  query: {includeMembers: 'true', includeFeatures: 'true'},
85
85
  tag: 'projects.get',
86
86
  })
@@ -130,7 +130,7 @@ export const project = defineFetcher<
130
130
  getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
131
131
  switchMap((client) =>
132
132
  client.observable.request<Project<boolean, boolean>>({
133
- uri: `/projects/${resolveProjectId(instance, options)}`,
133
+ url: `/projects/${resolveProjectId(instance, options)}`,
134
134
  query: buildQuery(normalizeProjectOptions(options)),
135
135
  tag: 'projects.get',
136
136
  }),
@@ -4,7 +4,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
4
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
5
5
  import {type StoreState} from '../store/createStoreState'
6
6
  import {hashString} from '../utils/hashString'
7
- import {insecureRandomId} from '../utils/ids'
7
+ import {randomId} from '../utils/ids'
8
8
  import {getProjectionState} from './getProjectionState'
9
9
  import {subscribeToStateAndFetchBatches} from './subscribeToStateAndFetchBatches'
10
10
  import {type ProjectionStoreState} from './types'
@@ -19,7 +19,7 @@ vi.mock('../utils/ids', async (importOriginal) => {
19
19
  // Mock implementation uses the external counter
20
20
  return {
21
21
  ...util,
22
- insecureRandomId: vi.fn(() => {
22
+ randomId: vi.fn(() => {
23
23
  const id = `testSubId_${++mockIdCounter}`
24
24
  return id
25
25
  }),
@@ -39,8 +39,7 @@ describe('getProjectionState', () => {
39
39
  let state: StoreState<ProjectionStoreState & {extra?: unknown}>
40
40
 
41
41
  beforeEach(() => {
42
- mockIdCounter = 0
43
- vi.mocked(insecureRandomId).mockClear()
42
+ vi.mocked(randomId).mockClear()
44
43
 
45
44
  // Capture state
46
45
  vi.mocked(subscribeToStateAndFetchBatches).mockImplementation((context) => {
@@ -49,6 +48,9 @@ describe('getProjectionState', () => {
49
48
  })
50
49
 
51
50
  instance = createSanityInstance({projectId: 'exampleProject', dataset: 'exampleDataset'})
51
+ // Reset after instance creation: the instanceId also comes from randomId,
52
+ // but the test ids should only count subscription-related calls
53
+ mockIdCounter = 0
52
54
  vi.useFakeTimers() // Enable fake timers for each test
53
55
  })
54
56
 
@@ -10,7 +10,7 @@ import {
10
10
  type StateSource,
11
11
  } from '../store/createStateSourceAction'
12
12
  import {hashString} from '../utils/hashString'
13
- import {insecureRandomId} from '../utils/ids'
13
+ import {randomId} from '../utils/ids'
14
14
  import {omitProperty} from '../utils/object'
15
15
  import {setCleanupTimeout} from '../utils/setCleanupTimeout'
16
16
  import {projectionStore} from './projectionStore'
@@ -85,7 +85,7 @@ export const _getProjectionState = bindActionByResourceAndPerspective(
85
85
  },
86
86
  onSubscribe: ({state}, options: ProjectionOptions<string, string, string, string>) => {
87
87
  const {projection, ...docHandle} = options
88
- const subscriptionId = insecureRandomId()
88
+ const subscriptionId = randomId(16)
89
89
  const documentId = getPublishedId(DocumentId(docHandle.documentId))
90
90
  const validProjection = validateProjection(projection)
91
91
  const projectionHash = hashString(validProjection)
@@ -35,7 +35,7 @@ describe('projects', () => {
35
35
  const result = await projects.resolveState(instance)
36
36
  expect(result).toEqual(list)
37
37
  expect(request).toHaveBeenCalledWith({
38
- uri: '/projects',
38
+ url: '/projects',
39
39
  query: {includeMembers: 'false', includeFeatures: 'true', onlyExplicitMembership: 'false'},
40
40
  tag: 'projects.list',
41
41
  })
@@ -58,7 +58,7 @@ describe('projects', () => {
58
58
  })
59
59
 
60
60
  expect(request).toHaveBeenCalledWith({
61
- uri: '/projects',
61
+ url: '/projects',
62
62
  query: {
63
63
  organizationId: 'org123',
64
64
  includeMembers: 'true',
@@ -58,7 +58,7 @@ export const projects = defineFetcher<
58
58
  getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
59
59
  switchMap((client) =>
60
60
  client.observable.request<Project<boolean, boolean>[]>({
61
- uri: '/projects',
61
+ url: '/projects',
62
62
  query: buildQuery(normalizeProjectsOptions(options)),
63
63
  tag: 'projects.list',
64
64
  }),
@@ -36,7 +36,7 @@ import {
36
36
  } from '../store/createStateSourceAction'
37
37
  import {type StoreState} from '../store/createStoreState'
38
38
  import {defineStore, type StoreContext} from '../store/defineStore'
39
- import {insecureRandomId} from '../utils/ids'
39
+ import {randomId} from '../utils/ids'
40
40
  import {setCleanupTimeout} from '../utils/setCleanupTimeout'
41
41
  import {
42
42
  QUERY_STATE_CLEAR_DELAY,
@@ -309,7 +309,7 @@ const _getQueryState = bindActionByResource(
309
309
  return queryState?.result
310
310
  },
311
311
  onSubscribe: ({state, instance}, options: QueryOptions) => {
312
- const subscriptionId = insecureRandomId()
312
+ const subscriptionId = randomId(16)
313
313
  const key = getQueryKey(instance, options)
314
314
 
315
315
  state.set('addSubscriber', addSubscriber(key, subscriptionId))
@@ -370,7 +370,7 @@ const _resolveQuery = bindActionByResource(
370
370
  // while a component is suspended leaves a subscriber-less key behind — the
371
371
  // component never commits, so no other subscriber exists — and its stored
372
372
  // error would be rethrown on every future mount without ever refetching.
373
- const subscriptionId = insecureRandomId()
373
+ const subscriptionId = randomId(16)
374
374
  state.set('addSubscriber', addSubscriber(key, subscriptionId))
375
375
 
376
376
  const aborted$ = signal
@@ -6,7 +6,7 @@ import {createSanityInstance} from './createSanityInstance'
6
6
  describe('createSanityInstance', () => {
7
7
  it('should create an instance with a unique instanceId and given config', () => {
8
8
  const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
9
- expect(typeof instance.instanceId).toBe('string')
9
+ expect(instance.instanceId).toMatch(/^[a-zA-Z0-9]{16}$/)
10
10
  expect(instance.config).toEqual({projectId: 'proj1', dataset: 'ds1'})
11
11
  expect(instance.isDisposed()).toBe(false)
12
12
  })
@@ -1,5 +1,5 @@
1
1
  import {type SanityConfig} from '../config/sanityConfig'
2
- import {insecureRandomId} from '../utils/ids'
2
+ import {randomId} from '../utils/ids'
3
3
  import {createLogger, type InstanceContext} from '../utils/logger'
4
4
 
5
5
  /**
@@ -10,7 +10,7 @@ import {createLogger, type InstanceContext} from '../utils/logger'
10
10
  export interface SanityInstance {
11
11
  /**
12
12
  * Unique identifier for this instance
13
- * @remarks Generated using crypto.randomUUID()
13
+ * @remarks Generated as a random 16-character alphanumeric ID
14
14
  */
15
15
  readonly instanceId: string
16
16
 
@@ -47,7 +47,7 @@ export interface SanityInstance {
47
47
  * @public
48
48
  */
49
49
  export function createSanityInstance(config: SanityConfig = {}): SanityInstance {
50
- const instanceId = crypto.randomUUID()
50
+ const instanceId = randomId(16)
51
51
  const disposeListeners = new Map<string, () => void>()
52
52
  const disposed = {current: false}
53
53
 
@@ -99,7 +99,7 @@ export function createSanityInstance(config: SanityConfig = {}): SanityInstance
99
99
  logger.info('Instance disposed')
100
100
  },
101
101
  onDispose: (cb) => {
102
- const listenerId = insecureRandomId()
102
+ const listenerId = randomId(16)
103
103
  disposeListeners.set(listenerId, cb)
104
104
  return () => {
105
105
  disposeListeners.delete(listenerId)
@@ -8,11 +8,6 @@ describe('createStoreInstance', () => {
8
8
  let instance: ReturnType<typeof createSanityInstance>
9
9
 
10
10
  beforeEach(() => {
11
- // Mock crypto for predictable instance IDs
12
- vi.stubGlobal('crypto', {
13
- randomUUID: () => 'test-uuid-1234',
14
- })
15
-
16
11
  instance = createSanityInstance({projectId: 'test', dataset: 'test'})
17
12
  })
18
13
 
@@ -1,4 +1,4 @@
1
- import {NEVER, of, startWith, Subject, throwError} from 'rxjs'
1
+ import {concatWith, NEVER, of, startWith, Subject, throwError} from 'rxjs'
2
2
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
3
 
4
4
  import {createSanityInstance, type SanityInstance} from './createSanityInstance'
@@ -65,6 +65,28 @@ describe('fetcherStore', () => {
65
65
  unsub3()
66
66
  })
67
67
 
68
+ it('revalidates a stale entry on re-subscribe when the fetch source does not complete', async () => {
69
+ let n = 0
70
+ const fetchSpy = vi.fn(() => of(++n).pipe(concatWith(NEVER)))
71
+ const fetcher = defineFetcher<[], number>({
72
+ name: 'swr-non-completing',
73
+ staleTime: 0,
74
+ getKey: () => 'k',
75
+ fetch: () => fetchSpy,
76
+ })
77
+
78
+ const first = fetcher.getState(instance)
79
+ const unsub1 = first.subscribe()
80
+ await vi.waitFor(() => expect(first.getCurrent().data).toBe(1))
81
+ unsub1()
82
+
83
+ const second = fetcher.getState(instance)
84
+ const unsub2 = second.subscribe()
85
+ await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2))
86
+ expect(second.getCurrent().data).toBe(2)
87
+ unsub2()
88
+ })
89
+
68
90
  it('invalidate refetches an actively-subscribed entry', async () => {
69
91
  let n = 0
70
92
  const fetcher = defineFetcher<[], number>({
@@ -1,4 +1,4 @@
1
- import {filter, firstValueFrom, map, type Observable, Subscription} from 'rxjs'
1
+ import {filter, firstValueFrom, map, type Observable, Subscription, take} from 'rxjs'
2
2
 
3
3
  import {setCleanupInterval, setCleanupTimeout} from '../utils/setCleanupTimeout'
4
4
  import {bindActionGlobally} from './createActionBinder'
@@ -490,6 +490,7 @@ const startFetch = (
490
490
  sub.add(
491
491
  def
492
492
  .fetch(instance)(...params)
493
+ .pipe(take(1))
493
494
  .subscribe({
494
495
  next: (data) => {
495
496
  const current = state.get().entries[key]
@@ -659,8 +660,7 @@ export function defineFetcher<TParams extends unknown[], TData>(
659
660
  }))
660
661
  startInterval(state, def, instance, params, key)
661
662
 
662
- // Defer the fetch decision off the subscribe call stack (see the
663
- // `delay(0)` precedent in the legacy fetcher store) to avoid a
663
+ // Defer the fetch decision off the subscribe call stack to avoid a
664
664
  // re-entrant cache write during subscription setup.
665
665
  queueMicrotask(() => {
666
666
  const entry = state.get().entries[key]
@@ -280,7 +280,7 @@ describe('createTelemetryManager', () => {
280
280
  const result = await manager.checkConsent()
281
281
  expect(result).toBe(true)
282
282
  expect(mockClient.request).toHaveBeenCalledWith(
283
- expect.objectContaining({uri: '/intake/telemetry-status'}),
283
+ expect.objectContaining({url: '/intake/telemetry-status'}),
284
284
  )
285
285
  })
286
286
 
@@ -86,7 +86,7 @@ export function createTelemetryManager(options: TelemetryManagerOptions): Teleme
86
86
  try {
87
87
  const client = getClient()
88
88
  const result = await client.request<{status: ConsentStatus}>({
89
- uri: '/intake/telemetry-status',
89
+ url: '/intake/telemetry-status',
90
90
  tag: CONSENT_TAG,
91
91
  })
92
92
  cachedConsent = result
@@ -120,7 +120,7 @@ export function createTelemetryManager(options: TelemetryManagerOptions): Teleme
120
120
  const client = getClient()
121
121
  log.debug('sending event batch', {batchSize: batch.length, environment})
122
122
  return client.request({
123
- uri: '/intake/batch',
123
+ url: '/intake/batch',
124
124
  method: 'POST',
125
125
  body: {projectId, batch: enrichBatch(batch)},
126
126
  tag: BATCH_TAG,
@@ -450,7 +450,7 @@ describe('usersStore', () => {
450
450
  )
451
451
  expect(specificRequest).toHaveBeenCalledWith({
452
452
  method: 'GET',
453
- uri: `/users/${projectUserId}`,
453
+ url: `/users/${projectUserId}`,
454
454
  tag: 'users.get',
455
455
  })
456
456