@sanity/sdk 2.11.1 → 2.13.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 (60) hide show
  1. package/dist/_chunks-dts/utils.d.ts +175 -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 +465 -131
  12. package/dist/index.js.map +1 -1
  13. package/package.json +11 -11
  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.test.ts +24 -0
  19. package/src/document/applyDocumentActions.ts +17 -5
  20. package/src/document/documentConstants.ts +7 -0
  21. package/src/document/documentStore.test.ts +69 -0
  22. package/src/document/documentStore.ts +42 -10
  23. package/src/document/events.test.ts +57 -2
  24. package/src/document/events.ts +43 -24
  25. package/src/document/listen.ts +1 -1
  26. package/src/document/permissions.test.ts +79 -0
  27. package/src/document/permissions.ts +8 -7
  28. package/src/document/processActions/create.ts +7 -4
  29. package/src/document/processActions/delete.ts +4 -4
  30. package/src/document/processActions/discard.ts +2 -2
  31. package/src/document/processActions/edit.ts +13 -47
  32. package/src/document/processActions/processActions.ts +53 -3
  33. package/src/document/processActions/publish.ts +4 -4
  34. package/src/document/processActions/releaseArchive.ts +77 -0
  35. package/src/document/processActions/releaseCreate.ts +59 -0
  36. package/src/document/processActions/releaseDelete.ts +65 -0
  37. package/src/document/processActions/releaseEdit.ts +37 -0
  38. package/src/document/processActions/releasePublish.ts +45 -0
  39. package/src/document/processActions/releaseSchedule.ts +87 -0
  40. package/src/document/processActions/releaseUtil.ts +31 -0
  41. package/src/document/processActions/shared.ts +108 -4
  42. package/src/document/processActions/unpublish.ts +3 -3
  43. package/src/document/processActions.test.ts +423 -1
  44. package/src/document/reducers.ts +44 -8
  45. package/src/document/resourceRules.test.ts +178 -0
  46. package/src/document/resourceRules.ts +117 -0
  47. package/src/releases/getPerspectiveState.test.ts +1 -1
  48. package/src/releases/releasesStore.test.ts +50 -1
  49. package/src/releases/releasesStore.ts +41 -18
  50. package/src/releases/utils/sortReleases.test.ts +2 -2
  51. package/src/releases/utils/sortReleases.ts +1 -1
  52. package/src/telemetry/environment.test.ts +119 -0
  53. package/src/telemetry/environment.ts +92 -0
  54. package/src/telemetry/{__telemetry__/sdk.telemetry.ts → events.ts} +9 -9
  55. package/src/telemetry/initTelemetry.test.ts +240 -16
  56. package/src/telemetry/initTelemetry.ts +39 -16
  57. package/src/telemetry/telemetryManager.test.ts +129 -65
  58. package/src/telemetry/telemetryManager.ts +41 -29
  59. package/src/telemetry/devMode.test.ts +0 -60
  60. package/src/telemetry/devMode.ts +0 -41
@@ -17,15 +17,18 @@ import {
17
17
  Observable,
18
18
  of,
19
19
  pairwise,
20
+ retry,
20
21
  startWith,
21
22
  Subject,
22
23
  switchMap,
23
24
  tap,
24
25
  throttle,
26
+ throwError,
25
27
  timer,
26
28
  withLatestFrom,
27
29
  } from 'rxjs'
28
30
 
31
+ import {getCurrentUserState} from '../auth/authStore'
29
32
  import {type ClientOptions, getClientState} from '../client/clientStore'
30
33
  import {
31
34
  type DocumentHandle,
@@ -44,7 +47,12 @@ import {type SanityInstance} from '../store/createSanityInstance'
44
47
  import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
45
48
  import {defineStore, type StoreContext} from '../store/defineStore'
46
49
  import {type DocumentAction} from './actions'
47
- import {API_VERSION, INITIAL_OUTGOING_THROTTLE_TIME} from './documentConstants'
50
+ import {
51
+ API_VERSION,
52
+ INITIAL_OUTGOING_THROTTLE_TIME,
53
+ OUT_OF_SYNC_RETRY_BASE_DELAY,
54
+ OUT_OF_SYNC_RETRY_MAX_DELAY,
55
+ } from './documentConstants'
48
56
  import {
49
57
  type DocumentEvent,
50
58
  type DocumentTransactionSubmissionResult,
@@ -60,6 +68,7 @@ import {
60
68
  type Grant,
61
69
  } from './permissions'
62
70
  import {ActionError} from './processActions/processActions'
71
+ import {isReleaseAction} from './processActions/releaseUtil'
63
72
  import {
64
73
  type AppliedTransaction,
65
74
  applyFirstQueuedTransaction,
@@ -81,6 +90,10 @@ export interface DocumentStoreState {
81
90
  applied: AppliedTransaction[]
82
91
  outgoing?: OutgoingTransaction
83
92
  grants?: Record<Grant, ExprNode>
93
+ /**
94
+ * The current user's identity (their user ID).
95
+ */
96
+ identity?: string
84
97
  error?: unknown
85
98
  sharedListener: SharedListener
86
99
  fetchDocument: (documentId: string) => Observable<SanityDocument | null>
@@ -140,6 +153,7 @@ export const documentStore = defineStore<DocumentStoreState, BoundResourceKey>({
140
153
  subscribeToSubscriptionsAndListenToDocuments(context),
141
154
  subscribeToAppliedAndSubmitNextTransaction(context),
142
155
  subscribeToClientAndFetchDatasetAcl(context),
156
+ subscribeToCurrentUserAndSetIdentity(context),
143
157
  ]
144
158
 
145
159
  return () => {
@@ -414,10 +428,11 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
414
428
  outgoing,
415
429
  }))
416
430
 
417
- // Any liveEdit action in the batch routes to the mutations API. For mixed batches
418
- // non-liveEdit operations (e.g. publish) lose atomicity, but that is acceptable
419
- // given how rare mixed batches are.
420
- if (outgoing.actions.some((action) => action.liveEdit)) {
431
+ // liveEdit transactions route to the mutations API; everything else routes
432
+ // to the actions API. processActions rejects transactions that mix the two,
433
+ // and reducers won't batch across that boundary, so a batch is always
434
+ // entirely liveEdit or entirely not.
435
+ if (outgoing.actions.some((action) => !isReleaseAction(action) && action.liveEdit)) {
421
436
  return client.observable
422
437
  .mutate(outgoing.outgoingMutations as Mutation[], {
423
438
  transactionId: outgoing.transactionId,
@@ -430,7 +445,6 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
430
445
  .pipe(revertOnError, toResult)
431
446
  }
432
447
 
433
- // Pure non-liveEdit transactions use the actions API.
434
448
  return client.observable
435
449
  .action(outgoing.outgoingActions as Action[], {
436
450
  transactionId: outgoing.transactionId,
@@ -496,10 +510,15 @@ const subscribeToSubscriptionsAndListenToDocuments = (
496
510
  switchMap((e) => {
497
511
  if (!e.add) return EMPTY
498
512
  return listen(context, e.id).pipe(
499
- catchError((error) => {
500
- // retry on `OutOfSyncError`
501
- if (error instanceof OutOfSyncError) listen(context, e.id)
502
- throw error
513
+ retry({
514
+ delay: (error, retryCount) => {
515
+ if (!(error instanceof OutOfSyncError)) return throwError(() => error)
516
+ const backoff = Math.min(
517
+ OUT_OF_SYNC_RETRY_BASE_DELAY * 2 ** (retryCount - 1),
518
+ OUT_OF_SYNC_RETRY_MAX_DELAY,
519
+ )
520
+ return timer(backoff)
521
+ },
503
522
  }),
504
523
  tap((remote) =>
505
524
  state.set('applyRemoteDocument', (prev) =>
@@ -547,3 +566,16 @@ const subscribeToClientAndFetchDatasetAcl = ({
547
566
  error: (error) => state.set('setError', {error}),
548
567
  })
549
568
  }
569
+
570
+ const subscribeToCurrentUserAndSetIdentity = ({
571
+ instance,
572
+ state,
573
+ }: StoreContext<DocumentStoreState, BoundResourceKey>) =>
574
+ getCurrentUserState(instance).observable.subscribe({
575
+ next: (currentUser) => state.set('setIdentity', {identity: currentUser?.id}),
576
+ // A transient identity-fetch failure (network blip, expired token, or a
577
+ // normal logout/re-login transition) should not brick all document
578
+ // operations. Reset the identity to `undefined` and keep going — GROQ's
579
+ // `identity()` then evaluates to null, matching the unauthenticated state.
580
+ error: () => state.set('setIdentity', {identity: undefined}),
581
+ })
@@ -1,6 +1,6 @@
1
1
  import {describe, expect, it} from 'vitest'
2
2
 
3
- import {type DocumentAction} from '../document/actions'
3
+ import {type Action, type DocumentAction} from '../document/actions'
4
4
  import {type DocumentEvent, getDocumentEvents} from '../document/events'
5
5
  import {type OutgoingTransaction} from '../document/reducers'
6
6
 
@@ -41,11 +41,66 @@ describe('getDocumentEvents', () => {
41
41
  expect(events).toHaveLength(outgoing.actions.length)
42
42
  events.forEach((event) => {
43
43
  const action = outgoing.actions.find(
44
- (a) => 'documentId' in event && event.documentId === a.documentId,
44
+ (a) => 'documentId' in a && 'documentId' in event && event.documentId === a.documentId,
45
45
  )
46
46
  expect(action).toBeDefined()
47
47
  expect(event.type).toEqual(expectedMap[action!.type])
48
48
  expect((event as Extract<DocumentEvent, {outgoing: unknown}>).outgoing).toBe(outgoing)
49
49
  })
50
50
  })
51
+
52
+ it('emits created/edited/deleted events for release.create/edit/delete with release doc IDs', () => {
53
+ const outgoing: OutgoingTransaction = {
54
+ transactionId: 'txn-release',
55
+ actions: [
56
+ {type: 'release.create', releaseId: 'r1'} as Action,
57
+ {type: 'release.edit', releaseId: 'r2', patch: {set: {}}} as Action,
58
+ {type: 'release.delete', releaseId: 'r3'} as Action,
59
+ ],
60
+ disableBatching: false,
61
+ batchedTransactionIds: [],
62
+ outgoingActions: [],
63
+ outgoingMutations: [],
64
+ base: {},
65
+ working: {},
66
+ previous: {},
67
+ previousRevs: {},
68
+ timestamp: '2025-02-06T00:00:00.000Z',
69
+ }
70
+
71
+ const events = getDocumentEvents(outgoing)
72
+ expect(events).toEqual([
73
+ {type: 'created', documentId: '_.releases.r1', outgoing},
74
+ {type: 'edited', documentId: '_.releases.r2', outgoing},
75
+ {type: 'deleted', documentId: '_.releases.r3', outgoing},
76
+ ])
77
+ })
78
+
79
+ it('skips release actions that have no local mutation (publish/schedule/archive/etc.)', () => {
80
+ const outgoing: OutgoingTransaction = {
81
+ transactionId: 'txn-release-noop',
82
+ actions: [
83
+ {type: 'release.publish', releaseId: 'r1'} as Action,
84
+ {
85
+ type: 'release.schedule',
86
+ releaseId: 'r2',
87
+ publishAt: '2026-01-01T00:00:00.000Z',
88
+ } as Action,
89
+ {type: 'release.unschedule', releaseId: 'r3'} as Action,
90
+ {type: 'release.archive', releaseId: 'r4'} as Action,
91
+ {type: 'release.unarchive', releaseId: 'r5'} as Action,
92
+ ],
93
+ disableBatching: false,
94
+ batchedTransactionIds: [],
95
+ outgoingActions: [],
96
+ outgoingMutations: [],
97
+ base: {},
98
+ working: {},
99
+ previous: {},
100
+ previousRevs: {},
101
+ timestamp: '2025-02-06T00:00:00.000Z',
102
+ }
103
+
104
+ expect(getDocumentEvents(outgoing)).toEqual([])
105
+ })
51
106
  })
@@ -1,6 +1,7 @@
1
1
  import {type MultipleMutationResult, type SanityClient} from '@sanity/client'
2
2
 
3
- import {type DocumentAction} from './actions'
3
+ import {type DocumentAction, type ReleaseAction} from './actions'
4
+ import {getReleaseDocumentId, isReleaseAction} from './processActions/releaseUtil'
4
5
  import {type OutgoingTransaction} from './reducers'
5
6
 
6
7
  /** @beta Response body from submitting an outgoing transaction (actions or mutations API). */
@@ -118,31 +119,49 @@ export interface DocumentDiscardedEvent {
118
119
  outgoing: OutgoingTransaction
119
120
  }
120
121
 
121
- export function getDocumentEvents(outgoing: OutgoingTransaction): DocumentEvent[] {
122
- const documentIdsByAction = Object.entries(
123
- outgoing.actions.reduce(
124
- (acc, {type, documentId}) => {
125
- const ids = acc[type] || new Set()
126
- if (documentId) ids.add(documentId)
127
- acc[type] = ids
128
- return acc
129
- },
130
- {} as Record<DocumentAction['type'], Set<string>>,
131
- ),
132
- ) as [DocumentAction['type'], Set<string>][]
122
+ // Release actions that write a mutation to the local release doc map onto
123
+ // the regular per-document events with `documentId = '_.releases.<releaseId>'`.
124
+ // The other release actions (publish/schedule/unschedule/archive/unarchive)
125
+ // don't mutate local state, so they aren't in the map and get skipped — they
126
+ // surface through the transaction-level `accepted`/`reverted` events instead.
127
+ const actionMap = {
128
+ 'document.create': 'created',
129
+ 'document.delete': 'deleted',
130
+ 'document.discard': 'discarded',
131
+ 'document.edit': 'edited',
132
+ 'document.publish': 'published',
133
+ 'document.unpublish': 'unpublished',
134
+ 'release.create': 'created',
135
+ 'release.edit': 'edited',
136
+ 'release.delete': 'deleted',
137
+ } satisfies Partial<Record<DocumentAction['type'] | ReleaseAction['type'], DocumentEvent['type']>>
138
+
139
+ type MappedActionType = keyof typeof actionMap
133
140
 
134
- const actionMap = {
135
- 'document.create': 'created',
136
- 'document.delete': 'deleted',
137
- 'document.discard': 'discarded',
138
- 'document.edit': 'edited',
139
- 'document.publish': 'published',
140
- 'document.unpublish': 'unpublished',
141
- } satisfies Record<DocumentAction['type'], DocumentEvent['type']>
141
+ export function getDocumentEvents(outgoing: OutgoingTransaction): DocumentEvent[] {
142
+ const documentIdsByAction = outgoing.actions.reduce(
143
+ (acc, action) => {
144
+ if (!(action.type in actionMap)) return acc
145
+ const documentId = isReleaseAction(action)
146
+ ? getReleaseDocumentId(action.releaseId)
147
+ : action.documentId
148
+ if (!documentId) return acc
149
+ const type = action.type as MappedActionType
150
+ const ids = acc[type] ?? new Set<string>()
151
+ ids.add(documentId)
152
+ acc[type] = ids
153
+ return acc
154
+ },
155
+ {} as Partial<Record<MappedActionType, Set<string>>>,
156
+ )
142
157
 
143
- return documentIdsByAction.flatMap(([actionType, documentIds]) =>
144
- Array.from(documentIds).map(
145
- (documentId): DocumentEvent => ({type: actionMap[actionType], documentId, outgoing}),
158
+ return Object.entries(documentIdsByAction).flatMap(([actionType, documentIds]) =>
159
+ Array.from(documentIds ?? []).map(
160
+ (documentId): DocumentEvent => ({
161
+ type: actionMap[actionType as MappedActionType],
162
+ documentId,
163
+ outgoing,
164
+ }),
146
165
  ),
147
166
  )
148
167
  }
@@ -20,7 +20,7 @@ import {type StoreContext} from '../store/defineStore'
20
20
  import {type DocumentStoreState} from './documentStore'
21
21
  import {processMutations} from './processMutations'
22
22
 
23
- const DEFAULT_MAX_BUFFER_SIZE = 20
23
+ export const DEFAULT_MAX_BUFFER_SIZE = 20
24
24
  const DEFAULT_DEADLINE_MS = 30000
25
25
 
26
26
  export interface RemoteDocument {
@@ -32,9 +32,11 @@ const alwaysDeny = parse('false')
32
32
  const createState = (
33
33
  docStates: Record<string, {local: unknown}>,
34
34
  grants?: Record<Grant, ExprNode>,
35
+ identity?: string,
35
36
  ): SyncTransactionState => ({
36
37
  documentStates: docStates as SyncTransactionState['documentStates'],
37
38
  grants,
39
+ identity,
38
40
  queued: [],
39
41
  applied: [],
40
42
  })
@@ -52,6 +54,18 @@ describe('createGrantsLookup', () => {
52
54
  expect(evaluateSync(grants[key], {params: {document: dummyDoc}}).data).toBe(true)
53
55
  })
54
56
  })
57
+
58
+ it('should ignore unknown permissions like "manage"', () => {
59
+ const datasetAcl = [
60
+ {filter: '_id != null', permissions: ['history', 'read', 'update', 'create', 'manage']},
61
+ ] as DatasetAcl
62
+ const grants = createGrantsLookup(datasetAcl)
63
+ const dummyDoc = {_id: 'doc1'}
64
+ ;(['read', 'update', 'create', 'history'] as Grant[]).forEach((key) => {
65
+ expect(grants[key]).toBeDefined()
66
+ expect(evaluateSync(grants[key], {params: {document: dummyDoc}}).data).toBe(true)
67
+ })
68
+ })
55
69
  })
56
70
 
57
71
  describe('calculatePermissions', () => {
@@ -234,4 +248,69 @@ describe('calculatePermissions', () => {
234
248
  const result2 = calculatePermissions({instance, state}, {actions: [{...action}]})
235
249
  expect(result1).toBe(result2)
236
250
  })
251
+
252
+ describe('identity-aware grants', () => {
253
+ // Mirrors the canvas ACL filter shape: only the document's creator may
254
+ // update it. Without an identity passed to groq, `identity()` is null and
255
+ // the expression collapses to null — which used to read as "denied".
256
+ const canvasUpdateAcl: DatasetAcl = [
257
+ {
258
+ filter: '_type == "sanity.canvas.document" && _system.createdBy == identity()',
259
+ permissions: ['read', 'update'],
260
+ },
261
+ ]
262
+
263
+ const canvasDoc = (createdBy: string): SanityDocument => ({
264
+ _id: 'canvas-1',
265
+ _type: 'sanity.canvas.document',
266
+ _createdAt: '2025-01-01T00:00:00.000Z',
267
+ _updatedAt: '2025-01-01T00:00:00.000Z',
268
+ _rev: 'rev-1',
269
+ _system: {createdBy},
270
+ })
271
+
272
+ const editAction: DocumentAction = {
273
+ documentId: 'canvas-1',
274
+ documentType: 'sanity.canvas.document',
275
+ type: 'document.edit',
276
+ liveEdit: true,
277
+ }
278
+
279
+ it('allows edits when identity matches the document creator', () => {
280
+ const state = createState(
281
+ {'canvas-1': {local: canvasDoc('user-A')}},
282
+ createGrantsLookup(canvasUpdateAcl),
283
+ 'user-A',
284
+ )
285
+ expect(calculatePermissions({instance, state}, {actions: [editAction]})).toEqual({
286
+ allowed: true,
287
+ })
288
+ })
289
+
290
+ it('denies edits when identity does not match the document creator', () => {
291
+ const state = createState(
292
+ {'canvas-1': {local: canvasDoc('user-A')}},
293
+ createGrantsLookup(canvasUpdateAcl),
294
+ 'user-B',
295
+ )
296
+ const result = calculatePermissions({instance, state}, {actions: [editAction]})
297
+ expect(result?.allowed).toBe(false)
298
+ expect(result?.reasons).toEqual(
299
+ expect.arrayContaining([expect.objectContaining({type: 'access', documentId: 'canvas-1'})]),
300
+ )
301
+ })
302
+
303
+ it('denies edits when identity is not yet loaded', () => {
304
+ const state = createState(
305
+ {'canvas-1': {local: canvasDoc('user-A')}},
306
+ createGrantsLookup(canvasUpdateAcl),
307
+ undefined,
308
+ )
309
+ const result = calculatePermissions({instance, state}, {actions: [editAction]})
310
+ expect(result?.allowed).toBe(false)
311
+ expect(result?.reasons).toEqual(
312
+ expect.arrayContaining([expect.objectContaining({type: 'access', documentId: 'canvas-1'})]),
313
+ )
314
+ })
315
+ })
237
316
  })
@@ -1,6 +1,6 @@
1
1
  import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
2
2
  import {type SanityDocument} from '@sanity/types'
3
- import {evaluateSync, type ExprNode, parse} from 'groq-js'
3
+ import {type ExprNode, parse} from 'groq-js'
4
4
  import {createSelector} from 'reselect'
5
5
 
6
6
  import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
@@ -8,6 +8,7 @@ import {type SelectorContext} from '../store/createStateSourceAction'
8
8
  import {MultiKeyWeakMap} from '../utils/MultiKeyWeakMap'
9
9
  import {type DocumentAction} from './actions'
10
10
  import {ActionError, PermissionActionError, processActions} from './processActions/processActions'
11
+ import {checkGrant} from './processActions/shared'
11
12
  import {type DocumentSet} from './processMutations'
12
13
  import {type SyncTransactionState} from './reducers'
13
14
 
@@ -29,6 +30,8 @@ export function createGrantsLookup(datasetAcl: DatasetAcl): Record<Grant, ExprNo
29
30
  for (const entry of datasetAcl) {
30
31
  for (const grant of entry.permissions) {
31
32
  const set = filtersByGrant[grant]
33
+ // Ignore permissions we don't model (e.g. "manage")
34
+ if (!set) continue
32
35
  set.add(entry.filter)
33
36
  filtersByGrant[grant] = set
34
37
  }
@@ -140,11 +143,6 @@ const memoizedActionsSelector = createSelector(
140
143
  },
141
144
  )
142
145
 
143
- function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean {
144
- const value = evaluateSync(grantExpr, {params: {document}})
145
- return value.type === 'boolean' && value.data
146
- }
147
-
148
146
  /** @beta */
149
147
  export interface PermissionDeniedReason {
150
148
  type: 'precondition' | 'access'
@@ -172,11 +170,13 @@ export function calculatePermissions(
172
170
  const _calculatePermissions = createSelector(
173
171
  [
174
172
  ({state: {grants}}: SelectorContext<SyncTransactionState>) => grants,
173
+ ({state: {identity}}: SelectorContext<SyncTransactionState>) => identity,
175
174
  documentsSelector,
176
175
  memoizedActionsSelector,
177
176
  ],
178
177
  (
179
178
  grants: Record<Grant, ExprNode> | undefined,
179
+ identity: string | undefined,
180
180
  documents: DocumentSet | undefined,
181
181
  actions: DocumentAction[] | undefined,
182
182
  ): DocumentPermissionsResult | undefined => {
@@ -195,6 +195,7 @@ const _calculatePermissions = createSelector(
195
195
  base: documents,
196
196
  timestamp,
197
197
  grants,
198
+ identity,
198
199
  })
199
200
  } catch (error) {
200
201
  if (error instanceof PermissionActionError) {
@@ -244,7 +245,7 @@ const _calculatePermissions = createSelector(
244
245
  documentId: docId,
245
246
  })
246
247
  }
247
- } else if (!checkGrant(grants.update, doc)) {
248
+ } else if (!checkGrant(grants.update, doc, identity)) {
248
249
  reasons.push({
249
250
  type: 'access',
250
251
  message: `You are not allowed to edit the document with ID "${docId}".`,
@@ -16,7 +16,7 @@ export function handleCreate(
16
16
  action: CreateDocumentAction,
17
17
  ctx: ActionHandlerContext,
18
18
  ): ActionHandlerResult {
19
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
19
+ const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
20
20
  let {base, working} = ctx
21
21
 
22
22
  const documentId = getId(action.documentId)
@@ -51,7 +51,7 @@ export function handleCreate(
51
51
  timestamp,
52
52
  })
53
53
 
54
- if (!checkGrant(grants.create, working[documentId] as SanityDocument)) {
54
+ if (!checkGrant(grants.create, working[documentId] as SanityDocument, identity)) {
55
55
  throw new PermissionActionError({
56
56
  documentId,
57
57
  transactionId,
@@ -111,13 +111,16 @@ export function handleCreate(
111
111
  timestamp,
112
112
  })
113
113
 
114
- if (versionId && !checkGrant(grants.create, working[versionId] as SanityDocument)) {
114
+ if (versionId && !checkGrant(grants.create, working[versionId] as SanityDocument, identity)) {
115
115
  throw new PermissionActionError({
116
116
  documentId,
117
117
  transactionId,
118
118
  message: `You do not have permission to create a release version for document "${documentId}".`,
119
119
  })
120
- } else if (!versionId && !checkGrant(grants.create, working[draftId] as SanityDocument)) {
120
+ } else if (
121
+ !versionId &&
122
+ !checkGrant(grants.create, working[draftId] as SanityDocument, identity)
123
+ ) {
121
124
  throw new PermissionActionError({
122
125
  documentId,
123
126
  transactionId,
@@ -16,7 +16,7 @@ export function handleDelete(
16
16
  action: DeleteDocumentAction,
17
17
  ctx: ActionHandlerContext,
18
18
  ): ActionHandlerResult {
19
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
19
+ const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
20
20
  let {base, working} = ctx
21
21
 
22
22
  const documentId = action.documentId
@@ -38,7 +38,7 @@ export function handleDelete(
38
38
  })
39
39
  }
40
40
 
41
- if (!checkGrant(grants.update, working[documentId])) {
41
+ if (!checkGrant(grants.update, working[documentId], identity)) {
42
42
  throw new PermissionActionError({
43
43
  documentId,
44
44
  transactionId,
@@ -72,9 +72,9 @@ export function handleDelete(
72
72
  })
73
73
  }
74
74
 
75
- const cantDeleteDraft = working[draftId] && !checkGrant(grants.update, working[draftId])
75
+ const cantDeleteDraft = working[draftId] && !checkGrant(grants.update, working[draftId], identity)
76
76
  const cantDeletePublished =
77
- working[publishedId] && !checkGrant(grants.update, working[publishedId])
77
+ working[publishedId] && !checkGrant(grants.update, working[publishedId], identity)
78
78
 
79
79
  if (cantDeleteDraft || cantDeletePublished) {
80
80
  throw new PermissionActionError({
@@ -16,7 +16,7 @@ export function handleDiscard(
16
16
  action: DiscardDocumentAction,
17
17
  ctx: ActionHandlerContext,
18
18
  ): ActionHandlerResult {
19
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
19
+ const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
20
20
  let {base, working} = ctx
21
21
 
22
22
  const documentId = getId(action.documentId)
@@ -43,7 +43,7 @@ export function handleDiscard(
43
43
  })
44
44
  }
45
45
 
46
- if (!checkGrant(grants.update, working[versionId])) {
46
+ if (!checkGrant(grants.update, working[versionId], identity)) {
47
47
  throw new PermissionActionError({
48
48
  documentId,
49
49
  transactionId,
@@ -9,6 +9,7 @@ import {
9
9
  ActionError,
10
10
  type ActionHandlerContext,
11
11
  type ActionHandlerResult,
12
+ applySingleDocPatch,
12
13
  checkGrant,
13
14
  PermissionActionError,
14
15
  } from './shared'
@@ -17,60 +18,25 @@ export function handleEdit(
17
18
  action: EditDocumentAction,
18
19
  ctx: ActionHandlerContext,
19
20
  ): ActionHandlerResult {
20
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
21
+ const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
21
22
  let {base, working} = ctx
22
23
 
23
24
  const documentId = getId(action.documentId)
24
25
 
25
26
  if (action.liveEdit) {
26
- // Single-document mode (liveEdit or release perspective): edit directly without draft logic
27
- const userPatches = action.patches?.map((patch) => ({patch: {id: documentId, ...patch}}))
28
-
29
- // skip this action if there are no associated patches
30
- if (!userPatches?.length) return {base, working}
31
-
32
- if (!working[documentId] || !base[documentId]) {
33
- throw new ActionError({
34
- documentId,
35
- transactionId,
36
- message: `Cannot edit document because it does not exist.`,
37
- })
38
- }
39
-
40
- const baseBefore = base[documentId] as SanityDocument
41
- if (userPatches) {
42
- base = processMutations({
43
- documents: base,
44
- transactionId,
45
- mutations: userPatches,
46
- timestamp,
47
- })
48
- }
49
-
50
- const baseAfter = base[documentId] as SanityDocument
51
- const patches = diffValue(baseBefore, baseAfter)
52
-
53
- const workingBefore = working[documentId] as SanityDocument
54
- if (!checkGrant(grants.update, workingBefore)) {
55
- throw new PermissionActionError({
56
- documentId,
57
- transactionId,
58
- message: `You do not have permission to edit document "${documentId}".`,
59
- })
60
- }
61
-
62
- const workingMutations = patches.map((patch) => ({patch: {id: documentId, ...patch}}))
63
-
64
- working = processMutations({
65
- documents: working,
27
+ const result = applySingleDocPatch({
28
+ base,
29
+ working,
30
+ documentId,
31
+ patches: action.patches,
66
32
  transactionId,
67
- mutations: workingMutations,
68
33
  timestamp,
34
+ grants,
35
+ identity,
69
36
  })
70
-
71
37
  // liveEdit documents use the mutation endpoint directly -- we don't send actions
72
- outgoingMutations.push(...workingMutations)
73
- return {base, working}
38
+ outgoingMutations.push(...result.workingMutations)
39
+ return {base: result.base, working: result.working}
74
40
  }
75
41
 
76
42
  const versionId = isReleasePerspective(action.perspective)
@@ -133,7 +99,7 @@ export function handleEdit(
133
99
  if (!isReleasePerspective(action.perspective) && !working[draftId] && working[publishedId]) {
134
100
  const newDraftFromPublished = {...working[publishedId], _id: draftId}
135
101
 
136
- if (!checkGrant(grants.create, newDraftFromPublished)) {
102
+ if (!checkGrant(grants.create, newDraftFromPublished, identity)) {
137
103
  throw new PermissionActionError({
138
104
  documentId,
139
105
  transactionId,
@@ -146,7 +112,7 @@ export function handleEdit(
146
112
 
147
113
  // the first if statement should make this never be null or undefined
148
114
  const workingBefore = working[patchDocumentId] ?? working[publishedId]
149
- if (!checkGrant(grants.update, workingBefore!)) {
115
+ if (!checkGrant(grants.update, workingBefore!, identity)) {
150
116
  throw new PermissionActionError({
151
117
  documentId,
152
118
  transactionId,