@sanity/sdk 2.11.1 → 2.12.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 (47) hide show
  1. package/dist/_chunks-dts/utils.d.ts +171 -19
  2. package/dist/_chunks-es/_internal.js +41 -26
  3. package/dist/_chunks-es/_internal.js.map +1 -1
  4. package/dist/_chunks-es/createGroqSearchFilter.js +15 -4
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  6. package/dist/_chunks-es/telemetryManager.js +25 -19
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -1
  8. package/dist/_chunks-es/version.js +1 -1
  9. package/dist/_exports/_internal.d.ts +27 -11
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +355 -75
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -8
  14. package/src/_exports/index.ts +23 -2
  15. package/src/config/sanityConfig.ts +12 -0
  16. package/src/document/actions.test.ts +112 -1
  17. package/src/document/actions.ts +148 -1
  18. package/src/document/applyDocumentActions.ts +4 -3
  19. package/src/document/documentStore.ts +6 -5
  20. package/src/document/events.test.ts +57 -2
  21. package/src/document/events.ts +43 -24
  22. package/src/document/processActions/edit.ts +9 -44
  23. package/src/document/processActions/processActions.ts +44 -3
  24. package/src/document/processActions/releaseArchive.ts +77 -0
  25. package/src/document/processActions/releaseCreate.ts +59 -0
  26. package/src/document/processActions/releaseDelete.ts +65 -0
  27. package/src/document/processActions/releaseEdit.ts +36 -0
  28. package/src/document/processActions/releasePublish.ts +45 -0
  29. package/src/document/processActions/releaseSchedule.ts +87 -0
  30. package/src/document/processActions/releaseUtil.ts +31 -0
  31. package/src/document/processActions/shared.ts +94 -2
  32. package/src/document/processActions.test.ts +423 -1
  33. package/src/document/reducers.ts +40 -5
  34. package/src/releases/getPerspectiveState.test.ts +1 -1
  35. package/src/releases/releasesStore.test.ts +50 -1
  36. package/src/releases/releasesStore.ts +41 -18
  37. package/src/releases/utils/sortReleases.test.ts +2 -2
  38. package/src/releases/utils/sortReleases.ts +1 -1
  39. package/src/telemetry/environment.test.ts +119 -0
  40. package/src/telemetry/environment.ts +92 -0
  41. package/src/telemetry/{__telemetry__/sdk.telemetry.ts → events.ts} +9 -9
  42. package/src/telemetry/initTelemetry.test.ts +240 -16
  43. package/src/telemetry/initTelemetry.ts +39 -16
  44. package/src/telemetry/telemetryManager.test.ts +129 -65
  45. package/src/telemetry/telemetryManager.ts +41 -29
  46. package/src/telemetry/devMode.test.ts +0 -60
  47. package/src/telemetry/devMode.ts +0 -41
@@ -1,12 +1,13 @@
1
1
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
2
2
 
3
+ import {getTokenState} from '../auth/authStore'
3
4
  import {createSanityInstance} from '../store/createSanityInstance'
4
- import {isDevMode} from './devMode'
5
+ import {getTelemetryEnvironment} from './environment'
5
6
  import {getTelemetryManager, initTelemetry, trackHookMounted} from './initTelemetry'
6
7
  import {createTelemetryManager} from './telemetryManager'
7
8
 
8
- vi.mock('./devMode', () => ({
9
- isDevMode: vi.fn(() => false),
9
+ vi.mock('./environment', () => ({
10
+ getTelemetryEnvironment: vi.fn(() => null),
10
11
  }))
11
12
 
12
13
  vi.mock('./telemetryManager', () => ({
@@ -14,7 +15,7 @@ vi.mock('./telemetryManager', () => ({
14
15
  checkConsent: vi.fn(() => Promise.resolve(true)),
15
16
  logSessionStarted: vi.fn(),
16
17
  logHookFirstUsed: vi.fn(),
17
- logDevError: vi.fn(),
18
+ logError: vi.fn(),
18
19
  endSession: vi.fn(),
19
20
  dispose: vi.fn(),
20
21
  hooksUsed: new Set(),
@@ -32,6 +33,53 @@ vi.mock('../auth/authStore', () => ({
32
33
  })),
33
34
  }))
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
+
35
83
  /**
36
84
  * Flush the microtask queue so the dynamic imports in initTelemetry
37
85
  * have time to resolve before assertions run.
@@ -47,8 +95,8 @@ describe('initTelemetry', () => {
47
95
  vi.restoreAllMocks()
48
96
  })
49
97
 
50
- it('does nothing when dev mode is disabled', async () => {
51
- vi.mocked(isDevMode).mockReturnValue(false)
98
+ it('does nothing when the environment is not eligible', async () => {
99
+ vi.mocked(getTelemetryEnvironment).mockReturnValue(null)
52
100
 
53
101
  const instance = createSanityInstance()
54
102
 
@@ -60,7 +108,7 @@ describe('initTelemetry', () => {
60
108
  })
61
109
 
62
110
  it('does nothing when no projectId is provided', async () => {
63
- vi.mocked(isDevMode).mockReturnValue(true)
111
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
64
112
 
65
113
  const instance = createSanityInstance()
66
114
  initTelemetry(instance, '')
@@ -70,8 +118,8 @@ describe('initTelemetry', () => {
70
118
  instance.dispose()
71
119
  })
72
120
 
73
- it('initializes telemetry in dev mode with a projectId', async () => {
74
- vi.mocked(isDevMode).mockReturnValue(true)
121
+ it('initializes telemetry in development mode with a projectId', async () => {
122
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
75
123
 
76
124
  const instance = createSanityInstance()
77
125
 
@@ -82,6 +130,7 @@ describe('initTelemetry', () => {
82
130
  expect.objectContaining({
83
131
  sessionId: instance.instanceId,
84
132
  projectId: 'abc123',
133
+ environment: 'development',
85
134
  }),
86
135
  )
87
136
 
@@ -96,8 +145,27 @@ describe('initTelemetry', () => {
96
145
  instance.dispose()
97
146
  })
98
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
+
99
167
  it('registers manager in the WeakMap', async () => {
100
- vi.mocked(isDevMode).mockReturnValue(true)
168
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
101
169
 
102
170
  const instance = createSanityInstance()
103
171
 
@@ -110,7 +178,7 @@ describe('initTelemetry', () => {
110
178
  })
111
179
 
112
180
  it('does not initialize if instance is already disposed', async () => {
113
- vi.mocked(isDevMode).mockReturnValue(true)
181
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
114
182
 
115
183
  const instance = createSanityInstance()
116
184
 
@@ -125,7 +193,7 @@ describe('initTelemetry', () => {
125
193
  })
126
194
 
127
195
  it('calls endSession and removes manager on instance dispose', async () => {
128
- vi.mocked(isDevMode).mockReturnValue(true)
196
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
129
197
 
130
198
  const instance = createSanityInstance()
131
199
 
@@ -142,7 +210,7 @@ describe('initTelemetry', () => {
142
210
  })
143
211
 
144
212
  it('skips telemetry entirely when user has not opted in', async () => {
145
- vi.mocked(isDevMode).mockReturnValue(true)
213
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
146
214
 
147
215
  const instance = createSanityInstance()
148
216
 
@@ -150,7 +218,7 @@ describe('initTelemetry', () => {
150
218
  checkConsent: vi.fn(() => Promise.resolve(false)),
151
219
  logSessionStarted: vi.fn(),
152
220
  logHookFirstUsed: vi.fn(),
153
- logDevError: vi.fn(),
221
+ logError: vi.fn(),
154
222
  endSession: vi.fn(),
155
223
  dispose: vi.fn(),
156
224
  hooksUsed: new Set(),
@@ -171,7 +239,7 @@ describe('initTelemetry', () => {
171
239
  })
172
240
 
173
241
  it('uses perspective from config when available', async () => {
174
- vi.mocked(isDevMode).mockReturnValue(true)
242
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
175
243
 
176
244
  const instance = createSanityInstance({perspective: 'previewDrafts'})
177
245
 
@@ -189,7 +257,7 @@ describe('initTelemetry', () => {
189
257
  })
190
258
 
191
259
  it('flushes hooks buffered before manager is ready', async () => {
192
- vi.mocked(isDevMode).mockReturnValue(true)
260
+ vi.mocked(getTelemetryEnvironment).mockReturnValue('development')
193
261
 
194
262
  const instance = createSanityInstance()
195
263
 
@@ -205,4 +273,160 @@ describe('initTelemetry', () => {
205
273
 
206
274
  instance.dispose()
207
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
+ })
208
432
  })
@@ -1,6 +1,6 @@
1
1
  import {type SanityInstance} from '../store/createSanityInstance'
2
2
  import {createLogger} from '../utils/logger'
3
- import {isDevMode} from './devMode'
3
+ import {getTelemetryEnvironment} from './environment'
4
4
  import {type TelemetryManager} from './telemetryManager'
5
5
 
6
6
  const DEFAULT_TELEMETRY_API_VERSION = '2024-11-12'
@@ -20,10 +20,23 @@ const pendingHooks = new WeakMap<SanityInstance, Set<string>>()
20
20
  const initInFlight = new WeakSet<SanityInstance>()
21
21
 
22
22
  /**
23
- * Initializes dev-mode telemetry for a SDK instance if the environment
24
- * qualifies. Both `telemetryManager` and `clientStore` are dynamically
25
- * imported to avoid circular dependencies and to keep telemetry code
26
- * out of production bundles via code splitting.
23
+ * Initializes telemetry for a SDK instance if the runtime environment
24
+ * qualifies. The environment is resolved by `getTelemetryEnvironment()`:
25
+ *
26
+ * - `'development'` local dev servers (`localhost` / `127.0.0.1`, or
27
+ * Node with `NODE_ENV=development`). This is the original opt-in
28
+ * surface.
29
+ * - `'production'` — apps deployed to Sanity-controlled domains
30
+ * (e.g. `*.sanity.studio`, the dashboard). End users are
31
+ * authenticated Sanity users with Populus consent records, so we
32
+ * apply the same consent gate as the Studio's `telemetry-sink`.
33
+ *
34
+ * Apps on customer-controlled domains return `null` and skip telemetry
35
+ * entirely.
36
+ *
37
+ * `telemetryManager` and `clientStore` are dynamically imported so the
38
+ * telemetry code path stays out of production bundles for apps that
39
+ * don't qualify. Only the lightweight environment check runs at boot.
27
40
  *
28
41
  * The `projectId` must be passed explicitly because the resource
29
42
  * configuration is typically set by the React layer after the
@@ -32,8 +45,9 @@ const initInFlight = new WeakSet<SanityInstance>()
32
45
  * @internal
33
46
  */
34
47
  export function initTelemetry(instance: SanityInstance, projectId: string): void {
35
- if (!isDevMode()) {
36
- logger.trace('initTelemetry skipped: not dev mode', {internal: true})
48
+ const environment = getTelemetryEnvironment()
49
+ if (!environment) {
50
+ logger.trace('initTelemetry skipped: environment not eligible', {internal: true})
37
51
  return
38
52
  }
39
53
  if (!projectId) {
@@ -45,7 +59,7 @@ export function initTelemetry(instance: SanityInstance, projectId: string): void
45
59
  }
46
60
  initInFlight.add(instance)
47
61
 
48
- logger.debug('initializing telemetry', {projectId})
62
+ logger.debug('initializing telemetry', {projectId, environment})
49
63
 
50
64
  Promise.all([
51
65
  import('./telemetryManager'),
@@ -77,15 +91,23 @@ export function initTelemetry(instance: SanityInstance, projectId: string): void
77
91
  cleanup.unsubscribe()
78
92
  resolve(false)
79
93
  })
94
+ // The token observable is a `shareReplay({bufferSize: 1, refCount: true})`
95
+ // (see `createStateSourceAction`), so it can deliver a buffered value
96
+ // synchronously while we're still inside `.subscribe(cb)`. At that
97
+ // moment `sub` is in the TDZ, so the callback can't reach it. We
98
+ // gate on a `received` flag and let the post-subscribe block do the
99
+ // unsubscribe in the sync-emission case.
100
+ let received = false
80
101
  const sub = getTokenState(instance).observable.subscribe((t) => {
81
- if (t) {
82
- logger.debug('auth token received')
83
- sub.unsubscribe()
84
- unsub()
85
- resolve(true)
86
- }
102
+ if (received || !t) return
103
+ received = true
104
+ logger.debug('auth token received')
105
+ unsub()
106
+ resolve(true)
107
+ cleanup.unsubscribe()
87
108
  })
88
109
  cleanup.unsubscribe = () => sub.unsubscribe()
110
+ if (received) cleanup.unsubscribe()
89
111
  })
90
112
  if (!hasToken || instance.isDisposed()) {
91
113
  initInFlight.delete(instance)
@@ -98,6 +120,7 @@ export function initTelemetry(instance: SanityInstance, projectId: string): void
98
120
  sessionId: instance.instanceId,
99
121
  getClient: () => getClient(instance, {apiVersion: DEFAULT_TELEMETRY_API_VERSION}),
100
122
  projectId,
123
+ environment,
101
124
  })
102
125
 
103
126
  const consented = await manager.checkConsent()
@@ -128,7 +151,7 @@ export function initTelemetry(instance: SanityInstance, projectId: string): void
128
151
  ? 'studio'
129
152
  : 'default'
130
153
 
131
- logger.info('telemetry session started', {projectId, perspective, authMethod})
154
+ logger.info('telemetry session started', {projectId, perspective, authMethod, environment})
132
155
  manager.logSessionStarted({
133
156
  projectId,
134
157
  perspective,
@@ -165,7 +188,7 @@ export function getTelemetryManager(instance: SanityInstance): TelemetryManager
165
188
  * @internal
166
189
  */
167
190
  export function trackHookMounted(instance: SanityInstance, hookName: string): void {
168
- if (!isDevMode()) return
191
+ if (!getTelemetryEnvironment()) return
169
192
 
170
193
  const manager = findManager(instance)
171
194
  if (manager) {