@sanity/sdk 2.7.0 → 2.9.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 (92) hide show
  1. package/dist/_chunks-dts/utils.d.ts +2396 -0
  2. package/dist/_chunks-es/_internal.js +129 -0
  3. package/dist/_chunks-es/_internal.js.map +1 -0
  4. package/dist/_chunks-es/createGroqSearchFilter.js +1460 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  6. package/dist/_chunks-es/telemetryManager.js +87 -0
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  8. package/dist/_chunks-es/version.js +7 -0
  9. package/dist/_chunks-es/version.js.map +1 -0
  10. package/dist/_exports/_internal.d.ts +64 -0
  11. package/dist/_exports/_internal.js +20 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +2 -2343
  14. package/dist/index.js +383 -1777
  15. package/dist/index.js.map +1 -1
  16. package/package.json +11 -4
  17. package/src/_exports/_internal.ts +14 -0
  18. package/src/_exports/index.ts +10 -1
  19. package/src/auth/authStore.test.ts +150 -1
  20. package/src/auth/authStore.ts +11 -11
  21. package/src/auth/dashboardAuth.ts +2 -2
  22. package/src/auth/handleAuthCallback.ts +9 -3
  23. package/src/auth/logout.test.ts +1 -1
  24. package/src/auth/logout.ts +1 -1
  25. package/src/auth/refreshStampedToken.test.ts +118 -1
  26. package/src/auth/refreshStampedToken.ts +3 -2
  27. package/src/auth/standaloneAuth.ts +9 -3
  28. package/src/auth/studioAuth.ts +34 -7
  29. package/src/auth/studioModeAuth.ts +2 -1
  30. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +10 -2
  31. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +5 -1
  32. package/src/auth/subscribeToStorageEventsAndSetToken.ts +2 -2
  33. package/src/auth/utils.ts +33 -0
  34. package/src/client/clientStore.test.ts +14 -0
  35. package/src/client/clientStore.ts +2 -1
  36. package/src/comlink/node/getNodeState.ts +2 -1
  37. package/src/config/sanityConfig.ts +6 -0
  38. package/src/document/actions.ts +18 -11
  39. package/src/document/applyDocumentActions.test.ts +7 -6
  40. package/src/document/applyDocumentActions.ts +10 -4
  41. package/src/document/documentStore.test.ts +536 -188
  42. package/src/document/documentStore.ts +142 -76
  43. package/src/document/events.ts +7 -2
  44. package/src/document/permissions.test.ts +18 -16
  45. package/src/document/permissions.ts +35 -11
  46. package/src/document/processActions.test.ts +359 -32
  47. package/src/document/processActions.ts +104 -76
  48. package/src/document/reducers.test.ts +117 -29
  49. package/src/document/reducers.ts +43 -36
  50. package/src/document/sharedListener.ts +16 -6
  51. package/src/document/util.ts +14 -0
  52. package/src/favorites/favorites.test.ts +9 -2
  53. package/src/presence/bifurTransport.ts +6 -1
  54. package/src/preview/getPreviewState.test.ts +115 -98
  55. package/src/preview/getPreviewState.ts +38 -60
  56. package/src/preview/previewProjectionUtils.test.ts +179 -0
  57. package/src/preview/previewProjectionUtils.ts +93 -0
  58. package/src/preview/resolvePreview.test.ts +42 -25
  59. package/src/preview/resolvePreview.ts +29 -10
  60. package/src/preview/{previewStore.ts → types.ts} +8 -17
  61. package/src/projection/getProjectionState.test.ts +16 -16
  62. package/src/projection/getProjectionState.ts +2 -1
  63. package/src/projection/projectionQuery.ts +2 -3
  64. package/src/projection/types.ts +1 -1
  65. package/src/query/queryStore.ts +2 -1
  66. package/src/releases/getPerspectiveState.ts +7 -6
  67. package/src/releases/releasesStore.test.ts +20 -5
  68. package/src/releases/releasesStore.ts +20 -8
  69. package/src/store/createStateSourceAction.test.ts +62 -0
  70. package/src/store/createStateSourceAction.ts +34 -39
  71. package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
  72. package/src/telemetry/devMode.test.ts +52 -0
  73. package/src/telemetry/devMode.ts +40 -0
  74. package/src/telemetry/initTelemetry.test.ts +225 -0
  75. package/src/telemetry/initTelemetry.ts +205 -0
  76. package/src/telemetry/telemetryManager.test.ts +263 -0
  77. package/src/telemetry/telemetryManager.ts +187 -0
  78. package/src/users/usersStore.test.ts +1 -0
  79. package/src/users/usersStore.ts +5 -1
  80. package/src/utils/createFetcherStore.test.ts +6 -4
  81. package/src/utils/createFetcherStore.ts +2 -1
  82. package/src/utils/getStagingApiHost.test.ts +21 -0
  83. package/src/utils/getStagingApiHost.ts +14 -0
  84. package/src/utils/ids.test.ts +1 -29
  85. package/src/utils/ids.ts +0 -10
  86. package/src/utils/setCleanupTimeout.ts +24 -0
  87. package/src/preview/previewQuery.test.ts +0 -236
  88. package/src/preview/previewQuery.ts +0 -153
  89. package/src/preview/previewStore.test.ts +0 -36
  90. package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
  91. package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
  92. package/src/preview/util.ts +0 -13
@@ -1,5 +1,5 @@
1
- import {type Action} from '@sanity/client'
2
- import {getPublishedId} from '@sanity/client/csm'
1
+ import {type Action, type Mutation} from '@sanity/client'
2
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
3
3
  import {jsonMatch} from '@sanity/json-match'
4
4
  import {type SanityDocument} from 'groq'
5
5
  import {type ExprNode} from 'groq-js'
@@ -26,30 +26,45 @@ import {
26
26
  withLatestFrom,
27
27
  } from 'rxjs'
28
28
 
29
- import {getClientState} from '../client/clientStore'
30
- import {type DocumentHandle} from '../config/sanityConfig'
29
+ import {type ClientOptions, getClientState} from '../client/clientStore'
31
30
  import {
32
- bindActionByDataset,
33
- type BoundDatasetKey,
31
+ type DocumentHandle,
32
+ type DocumentSource,
33
+ isCanvasSource,
34
+ isDatasetSource,
35
+ isMediaLibrarySource,
36
+ } from '../config/sanityConfig'
37
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
38
+ import {
39
+ bindActionBySource,
40
+ type BoundSourceKey,
34
41
  type StoreAction,
35
42
  } from '../store/createActionBinder'
36
43
  import {type SanityInstance} from '../store/createSanityInstance'
37
44
  import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
38
45
  import {defineStore, type StoreContext} from '../store/defineStore'
39
- import {getDraftId} from '../utils/ids'
40
46
  import {type DocumentAction} from './actions'
41
47
  import {API_VERSION, INITIAL_OUTGOING_THROTTLE_TIME} from './documentConstants'
42
- import {type DocumentEvent, getDocumentEvents} from './events'
48
+ import {
49
+ type DocumentEvent,
50
+ type DocumentTransactionSubmissionResult,
51
+ getDocumentEvents,
52
+ } from './events'
43
53
  import {listen, OutOfSyncError} from './listen'
44
54
  import {type JsonMatch} from './patchOperations'
45
- import {calculatePermissions, createGrantsLookup, type DatasetAcl, type Grant} from './permissions'
55
+ import {
56
+ calculatePermissions,
57
+ createGrantsLookup,
58
+ type DatasetAcl,
59
+ type DocumentPermissionsResult,
60
+ type Grant,
61
+ } from './permissions'
46
62
  import {ActionError} from './processActions'
47
63
  import {
48
64
  type AppliedTransaction,
49
65
  applyFirstQueuedTransaction,
50
66
  applyRemoteDocument,
51
67
  cleanupOutgoingTransaction,
52
- getDocumentIdsFromActions,
53
68
  manageSubscriberIds,
54
69
  type OutgoingTransaction,
55
70
  type QueuedTransaction,
@@ -107,15 +122,15 @@ export interface DocumentState {
107
122
  unverifiedRevisions?: {[TTransactionId in string]?: UnverifiedDocumentRevision}
108
123
  }
109
124
 
110
- export const documentStore = defineStore<DocumentStoreState, BoundDatasetKey>({
125
+ export const documentStore = defineStore<DocumentStoreState, BoundSourceKey>({
111
126
  name: 'Document',
112
- getInitialState: (instance) => ({
127
+ getInitialState: (instance, {source}) => ({
113
128
  documentStates: {},
114
129
  // these can be emptied on refetch
115
130
  queued: [],
116
131
  applied: [],
117
- sharedListener: createSharedListener(instance),
118
- fetchDocument: createFetchDocument(instance),
132
+ sharedListener: createSharedListener(instance, source),
133
+ fetchDocument: createFetchDocument(instance, source),
119
134
  events: new Subject(),
120
135
  }),
121
136
  initialize(context) {
@@ -183,33 +198,32 @@ export function getDocumentState(
183
198
  return _getDocumentState(...args)
184
199
  }
185
200
 
186
- const _getDocumentState = bindActionByDataset(
201
+ const _getDocumentState = bindActionBySource(
187
202
  documentStore,
188
203
  createStateSourceAction({
189
204
  selector: ({state: {error, documentStates}}, options: DocumentOptions<string | undefined>) => {
190
- const {documentId, path, liveEdit} = options
205
+ const {documentId: docId, path, liveEdit, perspective} = options
206
+ const documentId = DocumentId(docId)
191
207
  if (error) throw error
208
+ let document: SanityDocument | null | undefined
192
209
 
193
210
  if (liveEdit) {
194
- // For liveEdit documents, only look at the single document
195
- const document = documentStates[documentId]?.local
196
- if (document === undefined) return undefined
197
- if (!path) return document
198
- const result = jsonMatch(document, path).next()
199
- if (result.done) return undefined
200
- const {value} = result.value
201
- return value
211
+ document = documentStates[documentId]?.local
212
+ } else {
213
+ let version: SanityDocument | null | undefined
214
+ if (isReleasePerspective(perspective)) {
215
+ const versionId = getVersionId(documentId, perspective.releaseName)
216
+ version = documentStates[versionId]?.local
217
+ // early exit if we don't have the version document and we're in a release perspective
218
+ if (version === undefined) return undefined
219
+ }
220
+ const draft = documentStates[getDraftId(documentId)]?.local
221
+ const published = documentStates[getPublishedId(documentId)]?.local
222
+ // early exit if we don't have all the documents for draft/published logic
223
+ if (draft === undefined || published === undefined) return undefined
224
+ document = version ?? draft ?? published
202
225
  }
203
226
 
204
- // Standard draft/published logic
205
- const draftId = getDraftId(documentId)
206
- const publishedId = getPublishedId(documentId)
207
- const draft = documentStates[draftId]?.local
208
- const published = documentStates[publishedId]?.local
209
-
210
- // wait for draft and published to be loaded before returning a value
211
- if (draft === undefined || published === undefined) return undefined
212
- const document = draft ?? published
213
227
  if (!path) return document
214
228
  const result = jsonMatch(document, path).next()
215
229
  if (result.done) return undefined
@@ -217,7 +231,7 @@ const _getDocumentState = bindActionByDataset(
217
231
  return value
218
232
  },
219
233
  onSubscribe: (context, options: DocumentOptions<string | undefined>) =>
220
- manageSubscriberIds(context, options.documentId, {expandDraftPublished: !options.liveEdit}),
234
+ manageSubscriberIds(context, [options]),
221
235
  }),
222
236
  )
223
237
 
@@ -241,7 +255,7 @@ export function resolveDocument(
241
255
  ): Promise<SanityDocument | null> {
242
256
  return _resolveDocument(...args)
243
257
  }
244
- const _resolveDocument = bindActionByDataset(
258
+ const _resolveDocument = bindActionBySource(
245
259
  documentStore,
246
260
  ({instance}, docHandle: DocumentHandle<string, string, string>) => {
247
261
  return firstValueFrom(
@@ -254,57 +268,59 @@ const _resolveDocument = bindActionByDataset(
254
268
  )
255
269
 
256
270
  /** @beta */
257
- export const getDocumentSyncStatus = bindActionByDataset(
271
+ export const getDocumentSyncStatus = bindActionBySource(
258
272
  documentStore,
259
273
  createStateSourceAction({
260
274
  selector: (
261
275
  {state: {error, documentStates: documents, outgoing, applied, queued}},
262
276
  doc: DocumentHandle,
263
277
  ) => {
264
- const documentId = typeof doc === 'string' ? doc : doc.documentId
278
+ const documentId = DocumentId(typeof doc === 'string' ? doc : doc.documentId)
265
279
  if (error) throw error
266
280
 
267
281
  if (doc.liveEdit) {
268
282
  // For liveEdit documents, only check the single document
269
- const document = documents[documentId]
270
- if (document === undefined) return undefined
271
- return !queued.length && !applied.length && !outgoing
283
+ if (documents[documentId] === undefined) return undefined
284
+ } else {
285
+ const version = isReleasePerspective(doc.perspective)
286
+ ? documents[getVersionId(documentId, doc.perspective.releaseName)]
287
+ : undefined
288
+ if (isReleasePerspective(doc.perspective) && version === undefined) return undefined
289
+ // Standard draft/published logic
290
+ const draft = documents[getDraftId(documentId)]
291
+ const published = documents[getPublishedId(documentId)]
292
+ if (draft === undefined || published === undefined) return undefined
272
293
  }
273
-
274
- // Standard draft/published logic
275
- const draftId = getDraftId(documentId)
276
- const publishedId = getPublishedId(documentId)
277
-
278
- const draft = documents[draftId]
279
- const published = documents[publishedId]
280
-
281
- if (draft === undefined || published === undefined) return undefined
282
294
  return !queued.length && !applied.length && !outgoing
283
295
  },
284
- onSubscribe: (context, doc: DocumentHandle) => manageSubscriberIds(context, doc.documentId),
296
+ onSubscribe: (context, doc: DocumentHandle) => {
297
+ return manageSubscriberIds(context, [doc])
298
+ },
285
299
  }),
286
300
  )
287
301
 
288
302
  type PermissionsStateOptions = {
303
+ source?: DocumentSource
289
304
  actions: DocumentAction[]
290
305
  }
291
306
 
292
307
  /** @beta */
293
- export const getPermissionsState = bindActionByDataset(
308
+ export const getPermissionsState = bindActionBySource(
294
309
  documentStore,
295
310
  createStateSourceAction({
296
311
  selector: calculatePermissions,
297
- onSubscribe: (context, {actions}: PermissionsStateOptions) =>
298
- manageSubscriberIds(context, getDocumentIdsFromActions(actions)),
312
+ onSubscribe: (context, {actions}: PermissionsStateOptions) => {
313
+ manageSubscriberIds(context, actions)
314
+ },
299
315
  }) as StoreAction<
300
316
  DocumentStoreState,
301
317
  [PermissionsStateOptions],
302
- StateSource<ReturnType<typeof calculatePermissions>>
318
+ StateSource<DocumentPermissionsResult>
303
319
  >,
304
320
  )
305
321
 
306
322
  /** @beta */
307
- export const resolvePermissions = bindActionByDataset(
323
+ export const resolvePermissions = bindActionBySource(
308
324
  documentStore,
309
325
  ({instance}, options: PermissionsStateOptions) => {
310
326
  return firstValueFrom(
@@ -314,16 +330,18 @@ export const resolvePermissions = bindActionByDataset(
314
330
  )
315
331
 
316
332
  /** @beta */
317
- export const subscribeDocumentEvents = bindActionByDataset(
333
+ export const subscribeDocumentEvents = bindActionBySource(
318
334
  documentStore,
319
- ({state}, eventHandler: (e: DocumentEvent) => void) => {
335
+ ({state}, options: {source?: DocumentSource; eventHandler: (e: DocumentEvent) => void}) => {
320
336
  const {events} = state.get()
321
- const subscription = events.subscribe(eventHandler)
337
+ const subscription = events.subscribe(options.eventHandler)
322
338
  return () => subscription.unsubscribe()
323
339
  },
324
340
  )
325
341
 
326
- const subscribeToQueuedAndApplyNextTransaction = ({state}: StoreContext<DocumentStoreState>) => {
342
+ const subscribeToQueuedAndApplyNextTransaction = ({
343
+ state,
344
+ }: StoreContext<DocumentStoreState, BoundSourceKey>) => {
327
345
  const {events} = state.get()
328
346
  return state.observable
329
347
  .pipe(
@@ -354,7 +372,8 @@ const subscribeToQueuedAndApplyNextTransaction = ({state}: StoreContext<Document
354
372
  const subscribeToAppliedAndSubmitNextTransaction = ({
355
373
  state,
356
374
  instance,
357
- }: StoreContext<DocumentStoreState>) => {
375
+ key: {source},
376
+ }: StoreContext<DocumentStoreState, BoundSourceKey>) => {
358
377
  const {events} = state.get()
359
378
 
360
379
  return state.observable
@@ -374,22 +393,52 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
374
393
  tap((next) => state.set('transitionAppliedTransactionsToOutgoing', next)),
375
394
  map((s) => s.outgoing),
376
395
  distinctUntilChanged(),
377
- withLatestFrom(getClientState(instance, {apiVersion: API_VERSION}).observable),
396
+ withLatestFrom(
397
+ getClientState(instance, {
398
+ apiVersion: API_VERSION,
399
+ // TODO: remove in v3 when we're ready for everything to be queried via source
400
+ source: source && !isDatasetSource(source) ? source : undefined,
401
+ }).observable,
402
+ ),
378
403
  concatMap(([outgoing, client]) => {
379
404
  if (!outgoing) return EMPTY
405
+
406
+ const revertOnError = catchError((error: unknown) => {
407
+ state.set('revertOutgoingTransaction', revertOutgoingTransaction)
408
+ const message = error instanceof Error ? error.message : 'Request failed'
409
+ events.next({type: 'reverted', message, outgoing, error})
410
+ return EMPTY
411
+ })
412
+
413
+ const toResult = map((result: unknown) => ({
414
+ result: result as DocumentTransactionSubmissionResult,
415
+ outgoing,
416
+ }))
417
+
418
+ // Any liveEdit action in the batch routes to the mutations API. For mixed batches
419
+ // non-liveEdit operations (e.g. publish) lose atomicity, but that is acceptable
420
+ // given how rare mixed batches are.
421
+ if (outgoing.actions.some((action) => action.liveEdit)) {
422
+ return client.observable
423
+ .mutate(outgoing.outgoingMutations as Mutation[], {
424
+ transactionId: outgoing.transactionId,
425
+ visibility: 'async',
426
+ returnDocuments: false,
427
+ returnFirst: false,
428
+ tag: 'document.mutate',
429
+ skipCrossDatasetReferenceValidation: true,
430
+ })
431
+ .pipe(revertOnError, toResult)
432
+ }
433
+
434
+ // Pure non-liveEdit transactions use the actions API.
380
435
  return client.observable
381
436
  .action(outgoing.outgoingActions as Action[], {
382
437
  transactionId: outgoing.transactionId,
383
438
  skipCrossDatasetReferenceValidation: true,
439
+ tag: 'document.action',
384
440
  })
385
- .pipe(
386
- catchError((error) => {
387
- state.set('revertOutgoingTransaction', revertOutgoingTransaction)
388
- events.next({type: 'reverted', message: error.message, outgoing, error})
389
- return EMPTY
390
- }),
391
- map((result) => ({result, outgoing})),
392
- )
441
+ .pipe(revertOnError, toResult)
393
442
  }),
394
443
  tap(({outgoing, result}) => {
395
444
  state.set('cleanupOutgoingTransaction', cleanupOutgoingTransaction)
@@ -401,7 +450,7 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
401
450
  }
402
451
 
403
452
  const subscribeToSubscriptionsAndListenToDocuments = (
404
- context: StoreContext<DocumentStoreState>,
453
+ context: StoreContext<DocumentStoreState, BoundSourceKey>,
405
454
  ) => {
406
455
  const {state} = context
407
456
  const {events} = state.get()
@@ -431,8 +480,8 @@ const subscribeToSubscriptionsAndListenToDocuments = (
431
480
  ...added.map((id) => ({id, add: true})),
432
481
  ...removed.map((id) => ({id, add: false})),
433
482
  ].sort((a, b) => {
434
- const aIsDraft = a.id === getDraftId(a.id)
435
- const bIsDraft = b.id === getDraftId(b.id)
483
+ const aIsDraft = a.id === getDraftId(DocumentId(a.id))
484
+ const bIsDraft = b.id === getDraftId(DocumentId(b.id))
436
485
 
437
486
  if (aIsDraft && bIsDraft) return a.id.localeCompare(b.id, 'en-US')
438
487
  if (aIsDraft) return -1
@@ -469,13 +518,30 @@ const subscribeToSubscriptionsAndListenToDocuments = (
469
518
  const subscribeToClientAndFetchDatasetAcl = ({
470
519
  instance,
471
520
  state,
472
- key: {projectId, dataset},
473
- }: StoreContext<DocumentStoreState, BoundDatasetKey>) => {
474
- return getClientState(instance, {apiVersion: API_VERSION})
521
+ key: {source},
522
+ }: StoreContext<DocumentStoreState, BoundSourceKey>) => {
523
+ const clientOptions: ClientOptions = {apiVersion: API_VERSION}
524
+ // TODO: remove in v3 when we're ready for everything to be queried via source
525
+ if (source && !isDatasetSource(source)) {
526
+ clientOptions.source = source
527
+ }
528
+
529
+ let uri: string
530
+ if (source && isDatasetSource(source)) {
531
+ uri = `/projects/${source.projectId}/datasets/${source.dataset}/acl`
532
+ } else if (source && isMediaLibrarySource(source)) {
533
+ uri = `/media-libraries/${source.mediaLibraryId}/acl`
534
+ } else if (source && isCanvasSource(source)) {
535
+ uri = `/canvases/${source.canvasId}/acl`
536
+ } else {
537
+ throw new Error(`Received invalid source: ${JSON.stringify(source)}`)
538
+ }
539
+
540
+ return getClientState(instance, clientOptions)
475
541
  .observable.pipe(
476
542
  switchMap((client) =>
477
543
  client.observable.request<DatasetAcl>({
478
- uri: `/projects/${projectId}/datasets/${dataset}/acl`,
544
+ uri,
479
545
  tag: 'acl.get',
480
546
  withCredentials: true,
481
547
  }),
@@ -1,8 +1,13 @@
1
- import {type SanityClient} from '@sanity/client'
1
+ import {type MultipleMutationResult, type SanityClient} from '@sanity/client'
2
2
 
3
3
  import {type DocumentAction} from './actions'
4
4
  import {type OutgoingTransaction} from './reducers'
5
5
 
6
+ /** @beta Response body from submitting an outgoing transaction (actions or mutations API). */
7
+ export type DocumentTransactionSubmissionResult =
8
+ | Awaited<ReturnType<SanityClient['action']>>
9
+ | MultipleMutationResult
10
+
6
11
  /** @beta */
7
12
  export type DocumentEvent =
8
13
  | ActionErrorEvent
@@ -35,7 +40,7 @@ export interface ActionErrorEvent {
35
40
  export interface TransactionAcceptedEvent {
36
41
  type: 'accepted'
37
42
  outgoing: OutgoingTransaction
38
- result: Awaited<ReturnType<SanityClient['action']>>
43
+ result: DocumentTransactionSubmissionResult
39
44
  }
40
45
  /**
41
46
  * @beta
@@ -1,9 +1,9 @@
1
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
1
2
  import {type SanityDocument} from '@sanity/types'
2
3
  import {evaluateSync, type ExprNode, parse} from 'groq-js'
3
4
  import {describe, expect, it} from 'vitest'
4
5
 
5
6
  import {createSanityInstance} from '../store/createSanityInstance'
6
- import {getDraftId, getPublishedId} from '../utils/ids'
7
7
  import {type DocumentAction} from './actions'
8
8
  import {calculatePermissions, createGrantsLookup, type DatasetAcl, type Grant} from './permissions'
9
9
  import {type SyncTransactionState} from './reducers'
@@ -66,8 +66,10 @@ describe('calculatePermissions', () => {
66
66
  // For a document.create action, the selector expects both published and draft keys.
67
67
  const state = createState(
68
68
  {
69
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Original Title')},
70
- [getDraftId('doc1')]: {local: null},
69
+ [getPublishedId(DocumentId('doc1'))]: {
70
+ local: createDoc(DocumentId('doc1'), 'Original Title'),
71
+ },
72
+ [getDraftId(DocumentId('doc1'))]: {local: null},
71
73
  },
72
74
  defaultGrants,
73
75
  )
@@ -83,7 +85,7 @@ describe('calculatePermissions', () => {
83
85
  // Missing the draft key will cause documentsSelector to return undefined.
84
86
  const state = createState(
85
87
  {
86
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
88
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
87
89
  // Missing getDraftId('doc1')
88
90
  },
89
91
  defaultGrants,
@@ -99,8 +101,8 @@ describe('calculatePermissions', () => {
99
101
  const deniedGrants = {...defaultGrants, create: alwaysDeny}
100
102
  const state = createState(
101
103
  {
102
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
103
- [getDraftId('doc1')]: {local: null},
104
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
105
+ [getDraftId(DocumentId('doc1'))]: {local: null},
104
106
  },
105
107
  deniedGrants,
106
108
  )
@@ -127,8 +129,8 @@ describe('calculatePermissions', () => {
127
129
  // Both published and draft documents are present as null.
128
130
  const state = createState(
129
131
  {
130
- [getPublishedId('doc1')]: {local: null},
131
- [getDraftId('doc1')]: {local: null},
132
+ [getPublishedId(DocumentId('doc1'))]: {local: null},
133
+ [getDraftId(DocumentId('doc1'))]: {local: null},
132
134
  },
133
135
  defaultGrants,
134
136
  )
@@ -153,8 +155,8 @@ describe('calculatePermissions', () => {
153
155
  const deniedGrants = {...defaultGrants, update: alwaysDeny}
154
156
  const state = createState(
155
157
  {
156
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
157
- [getDraftId('doc1')]: {local: createDoc(getDraftId('doc1'), 'Draft Title')},
158
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
159
+ [getDraftId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Draft Title')},
158
160
  },
159
161
  deniedGrants,
160
162
  )
@@ -179,8 +181,8 @@ describe('calculatePermissions', () => {
179
181
 
180
182
  it('should return undefined if grants are not provided', () => {
181
183
  const state = createState({
182
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
183
- [getDraftId('doc1')]: {local: null},
184
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
185
+ [getDraftId(DocumentId('doc1'))]: {local: null},
184
186
  })
185
187
  const actions: DocumentAction[] = [
186
188
  {documentId: 'doc1', type: 'document.create', documentType: 'article'},
@@ -192,8 +194,8 @@ describe('calculatePermissions', () => {
192
194
  // For document.delete, if the published document is missing, processActions throws an ActionError.
193
195
  const state = createState(
194
196
  {
195
- [getPublishedId('doc1')]: {local: null},
196
- [getDraftId('doc1')]: {local: null},
197
+ [getPublishedId(DocumentId('doc1'))]: {local: null},
198
+ [getDraftId(DocumentId('doc1'))]: {local: null},
197
199
  },
198
200
  defaultGrants,
199
201
  )
@@ -217,8 +219,8 @@ describe('calculatePermissions', () => {
217
219
  it('should memoize the result for identical state and actions inputs', () => {
218
220
  const state = createState(
219
221
  {
220
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
221
- [getDraftId('doc1')]: {local: null},
222
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
223
+ [getDraftId(DocumentId('doc1'))]: {local: null},
222
224
  },
223
225
  defaultGrants,
224
226
  )
@@ -1,9 +1,10 @@
1
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
1
2
  import {type SanityDocument} from '@sanity/types'
2
3
  import {evaluateSync, type ExprNode, parse} from 'groq-js'
3
4
  import {createSelector} from 'reselect'
4
5
 
6
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
5
7
  import {type SelectorContext} from '../store/createStateSourceAction'
6
- import {getDraftId, getPublishedId} from '../utils/ids'
7
8
  import {MultiKeyWeakMap} from '../utils/MultiKeyWeakMap'
8
9
  import {type DocumentAction} from './actions'
9
10
  import {ActionError, PermissionActionError, processActions} from './processActions'
@@ -71,7 +72,14 @@ const documentsSelector = createSelector(
71
72
  // For liveEdit documents, only fetch the single document
72
73
  if (action.liveEdit) return [action.documentId]
73
74
  // For standard documents, fetch both draft and published
74
- return [getPublishedId(action.documentId), getDraftId(action.documentId)]
75
+ const ids: string[] = [
76
+ getPublishedId(DocumentId(action.documentId)),
77
+ getDraftId(DocumentId(action.documentId)),
78
+ ]
79
+ if (isReleasePerspective(action.perspective)) {
80
+ ids.push(getVersionId(DocumentId(action.documentId), action.perspective.releaseName))
81
+ }
82
+ return ids
75
83
  })
76
84
  .flat(),
77
85
  )
@@ -210,16 +218,32 @@ const _calculatePermissions = createSelector(
210
218
  // Check edit actions with no patches
211
219
  if (action.type === 'document.edit' && !action.patches?.length) {
212
220
  const docId = action.documentId
213
- // For liveEdit documents, only check the single document
214
- const doc = action.liveEdit
215
- ? documents[docId]
216
- : (documents[getDraftId(docId)] ?? documents[getPublishedId(docId)])
221
+ let doc: SanityDocument | null | undefined
222
+ if (action.liveEdit) {
223
+ doc = documents[docId]
224
+ }
225
+ // don't allow users to edit version documents that don't exist
226
+ // they should be explicitly created first, as in studio
227
+ else if (isReleasePerspective(action.perspective)) {
228
+ doc = documents[getVersionId(DocumentId(docId), action.perspective.releaseName)]
229
+ } else {
230
+ doc =
231
+ documents[getDraftId(DocumentId(docId))] ?? documents[getPublishedId(DocumentId(docId))]
232
+ }
217
233
  if (!doc) {
218
- reasons.push({
219
- type: 'precondition',
220
- message: `The document with ID "${docId}" could not be found. Please check that it exists before editing.`,
221
- documentId: docId,
222
- })
234
+ if (isReleasePerspective(action.perspective)) {
235
+ reasons.push({
236
+ type: 'precondition',
237
+ message: `The version document with ID "${docId}" could not be found. Please create it or add it to the release first.`,
238
+ documentId: docId,
239
+ })
240
+ } else {
241
+ reasons.push({
242
+ type: 'precondition',
243
+ message: `The document with ID "${docId}" could not be found. Please check that it exists before editing.`,
244
+ documentId: docId,
245
+ })
246
+ }
223
247
  } else if (!checkGrant(grants.update, doc)) {
224
248
  reasons.push({
225
249
  type: 'access',