@sanity/sdk 2.12.0 → 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 (30) hide show
  1. package/dist/_chunks-dts/utils.d.ts +4 -0
  2. package/dist/_chunks-es/version.js +1 -1
  3. package/dist/index.js +160 -106
  4. package/dist/index.js.map +1 -1
  5. package/package.json +11 -11
  6. package/src/document/applyDocumentActions.test.ts +24 -0
  7. package/src/document/applyDocumentActions.ts +13 -2
  8. package/src/document/documentConstants.ts +7 -0
  9. package/src/document/documentStore.test.ts +69 -0
  10. package/src/document/documentStore.ts +36 -5
  11. package/src/document/listen.ts +1 -1
  12. package/src/document/permissions.test.ts +79 -0
  13. package/src/document/permissions.ts +8 -7
  14. package/src/document/processActions/create.ts +7 -4
  15. package/src/document/processActions/delete.ts +4 -4
  16. package/src/document/processActions/discard.ts +2 -2
  17. package/src/document/processActions/edit.ts +4 -3
  18. package/src/document/processActions/processActions.ts +9 -0
  19. package/src/document/processActions/publish.ts +4 -4
  20. package/src/document/processActions/releaseArchive.ts +4 -4
  21. package/src/document/processActions/releaseCreate.ts +2 -2
  22. package/src/document/processActions/releaseDelete.ts +2 -2
  23. package/src/document/processActions/releaseEdit.ts +2 -1
  24. package/src/document/processActions/releasePublish.ts +2 -2
  25. package/src/document/processActions/releaseSchedule.ts +4 -4
  26. package/src/document/processActions/shared.ts +15 -3
  27. package/src/document/processActions/unpublish.ts +3 -3
  28. package/src/document/reducers.ts +4 -3
  29. package/src/document/resourceRules.test.ts +178 -0
  30. package/src/document/resourceRules.ts +117 -0
@@ -12,7 +12,7 @@ export function handleReleaseArchive(
12
12
  action: ArchiveReleaseAction,
13
13
  ctx: ActionHandlerContext,
14
14
  ): ActionHandlerResult {
15
- const {base, working, grants, outgoingActions, transactionId} = ctx
15
+ const {base, working, grants, outgoingActions, transactionId, identity} = ctx
16
16
 
17
17
  const releaseDocumentId = getReleaseDocumentId(action.releaseId)
18
18
  const existing = working[releaseDocumentId] ?? base[releaseDocumentId]
@@ -24,7 +24,7 @@ export function handleReleaseArchive(
24
24
  })
25
25
  }
26
26
 
27
- if (!checkGrant(grants.update, existing)) {
27
+ if (!checkGrant(grants.update, existing, identity)) {
28
28
  throw new PermissionActionError({
29
29
  documentId: releaseDocumentId,
30
30
  transactionId,
@@ -48,7 +48,7 @@ export function handleReleaseUnarchive(
48
48
  action: UnarchiveReleaseAction,
49
49
  ctx: ActionHandlerContext,
50
50
  ): ActionHandlerResult {
51
- const {base, working, grants, outgoingActions, transactionId} = ctx
51
+ const {base, working, grants, outgoingActions, transactionId, identity} = ctx
52
52
 
53
53
  const releaseDocumentId = getReleaseDocumentId(action.releaseId)
54
54
  const existing = working[releaseDocumentId] ?? base[releaseDocumentId]
@@ -60,7 +60,7 @@ export function handleReleaseUnarchive(
60
60
  })
61
61
  }
62
62
 
63
- if (!checkGrant(grants.update, existing)) {
63
+ if (!checkGrant(grants.update, existing, identity)) {
64
64
  throw new PermissionActionError({
65
65
  documentId: releaseDocumentId,
66
66
  transactionId,
@@ -15,7 +15,7 @@ export function handleReleaseCreate(
15
15
  action: CreateReleaseAction,
16
16
  ctx: ActionHandlerContext,
17
17
  ): ActionHandlerResult {
18
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
18
+ const {transactionId, timestamp, grants, outgoingActions, outgoingMutations, identity} = ctx
19
19
  let {base, working} = ctx
20
20
 
21
21
  const releaseDocumentId = getReleaseDocumentId(action.releaseId)
@@ -40,7 +40,7 @@ export function handleReleaseCreate(
40
40
  base = processMutations({documents: base, transactionId, mutations, timestamp})
41
41
  working = processMutations({documents: working, transactionId, mutations, timestamp})
42
42
 
43
- if (!checkGrant(grants.create, working[releaseDocumentId] as SanityDocument)) {
43
+ if (!checkGrant(grants.create, working[releaseDocumentId] as SanityDocument, identity)) {
44
44
  throw new PermissionActionError({
45
45
  documentId: releaseDocumentId,
46
46
  transactionId,
@@ -19,7 +19,7 @@ export function handleReleaseDelete(
19
19
  action: DeleteReleaseAction,
20
20
  ctx: ActionHandlerContext,
21
21
  ): ActionHandlerResult {
22
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
22
+ const {transactionId, timestamp, grants, outgoingActions, outgoingMutations, identity} = ctx
23
23
  let {base, working} = ctx
24
24
 
25
25
  const releaseDocumentId = getReleaseDocumentId(action.releaseId)
@@ -42,7 +42,7 @@ export function handleReleaseDelete(
42
42
  })
43
43
  }
44
44
 
45
- if (!checkGrant(grants.update, existing)) {
45
+ if (!checkGrant(grants.update, existing, identity)) {
46
46
  throw new PermissionActionError({
47
47
  documentId: releaseDocumentId,
48
48
  transactionId,
@@ -6,7 +6,7 @@ export function handleReleaseEdit(
6
6
  action: EditReleaseAction,
7
7
  ctx: ActionHandlerContext,
8
8
  ): ActionHandlerResult {
9
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
9
+ const {transactionId, timestamp, grants, outgoingActions, outgoingMutations, identity} = ctx
10
10
  const {base, working} = ctx
11
11
 
12
12
  const releaseDocumentId = getReleaseDocumentId(action.releaseId)
@@ -19,6 +19,7 @@ export function handleReleaseEdit(
19
19
  transactionId,
20
20
  timestamp,
21
21
  grants,
22
+ identity,
22
23
  notFoundMessage: `Cannot edit release "${action.releaseId}" because it does not exist.`,
23
24
  permissionMessage: `You do not have permission to edit release "${action.releaseId}".`,
24
25
  })
@@ -12,7 +12,7 @@ export function handleReleasePublish(
12
12
  action: PublishReleaseAction,
13
13
  ctx: ActionHandlerContext,
14
14
  ): ActionHandlerResult {
15
- const {base, working, grants, outgoingActions, transactionId} = ctx
15
+ const {base, working, grants, outgoingActions, transactionId, identity} = ctx
16
16
 
17
17
  const releaseDocumentId = getReleaseDocumentId(action.releaseId)
18
18
  const existing = working[releaseDocumentId] ?? base[releaseDocumentId]
@@ -24,7 +24,7 @@ export function handleReleasePublish(
24
24
  })
25
25
  }
26
26
 
27
- if (!checkGrant(grants.update, existing)) {
27
+ if (!checkGrant(grants.update, existing, identity)) {
28
28
  throw new PermissionActionError({
29
29
  documentId: releaseDocumentId,
30
30
  transactionId,
@@ -12,7 +12,7 @@ export function handleReleaseSchedule(
12
12
  action: ScheduleReleaseAction,
13
13
  ctx: ActionHandlerContext,
14
14
  ): ActionHandlerResult {
15
- const {base, working, grants, outgoingActions, transactionId} = ctx
15
+ const {base, working, grants, outgoingActions, transactionId, identity} = ctx
16
16
 
17
17
  const releaseDocumentId = getReleaseDocumentId(action.releaseId)
18
18
 
@@ -33,7 +33,7 @@ export function handleReleaseSchedule(
33
33
  })
34
34
  }
35
35
 
36
- if (!checkGrant(grants.update, existing)) {
36
+ if (!checkGrant(grants.update, existing, identity)) {
37
37
  throw new PermissionActionError({
38
38
  documentId: releaseDocumentId,
39
39
  transactionId,
@@ -58,7 +58,7 @@ export function handleReleaseUnschedule(
58
58
  action: UnscheduleReleaseAction,
59
59
  ctx: ActionHandlerContext,
60
60
  ): ActionHandlerResult {
61
- const {base, working, grants, outgoingActions, transactionId} = ctx
61
+ const {base, working, grants, outgoingActions, transactionId, identity} = ctx
62
62
 
63
63
  const releaseDocumentId = getReleaseDocumentId(action.releaseId)
64
64
  const existing = working[releaseDocumentId] ?? base[releaseDocumentId]
@@ -70,7 +70,7 @@ export function handleReleaseUnschedule(
70
70
  })
71
71
  }
72
72
 
73
- if (!checkGrant(grants.update, existing)) {
73
+ if (!checkGrant(grants.update, existing, identity)) {
74
74
  throw new PermissionActionError({
75
75
  documentId: releaseDocumentId,
76
76
  transactionId,
@@ -12,6 +12,12 @@ export interface ActionHandlerContext {
12
12
  transactionId: string
13
13
  timestamp: string
14
14
  grants: Record<Grant, ExprNode>
15
+ /**
16
+ * The current user's ID, used by GROQ's `identity()` when evaluating ACL
17
+ * filters. May be `undefined` before the user has loaded; in that case
18
+ * `identity()` evaluates to null.
19
+ */
20
+ identity: string | undefined
15
21
  outgoingActions: HttpAction[]
16
22
  outgoingMutations: Mutation[]
17
23
  }
@@ -21,8 +27,12 @@ export interface ActionHandlerResult {
21
27
  working: DocumentSet
22
28
  }
23
29
 
24
- export function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean {
25
- const value = evaluateSync(grantExpr, {params: {document}})
30
+ export function checkGrant(
31
+ grantExpr: ExprNode,
32
+ document: SanityDocument,
33
+ identity: string | undefined,
34
+ ): boolean {
35
+ const value = evaluateSync(grantExpr, {params: {document}, identity})
26
36
  return value.type === 'boolean' && value.data
27
37
  }
28
38
 
@@ -55,6 +65,7 @@ interface ApplySingleDocPatchOptions {
55
65
  transactionId: string
56
66
  timestamp: string
57
67
  grants: Record<Grant, ExprNode>
68
+ identity: string | undefined
58
69
  /**
59
70
  * Error message thrown when the target document does not exist in either
60
71
  * the base or working set.
@@ -98,6 +109,7 @@ export function applySingleDocPatch({
98
109
  transactionId,
99
110
  timestamp,
100
111
  grants,
112
+ identity,
101
113
  notFoundMessage = 'Cannot edit document because it does not exist.',
102
114
  permissionMessage = `You do not have permission to edit document "${documentId}".`,
103
115
  }: ApplySingleDocPatchOptions): ApplySingleDocPatchResult {
@@ -120,7 +132,7 @@ export function applySingleDocPatch({
120
132
  const diffedPatches = diffValue(baseBefore, baseAfter) as PatchOperations[]
121
133
 
122
134
  const workingBefore = working[documentId] as SanityDocument
123
- if (!checkGrant(grants.update, workingBefore)) {
135
+ if (!checkGrant(grants.update, workingBefore, identity)) {
124
136
  throw new PermissionActionError({documentId, transactionId, message: permissionMessage})
125
137
  }
126
138
 
@@ -16,7 +16,7 @@ export function handleUnpublish(
16
16
  action: UnpublishDocumentAction,
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)
@@ -48,7 +48,7 @@ export function handleUnpublish(
48
48
  {createIfNotExists: newDraftFromPublished},
49
49
  ]
50
50
 
51
- if (!checkGrant(grants.update, sourceDoc)) {
51
+ if (!checkGrant(grants.update, sourceDoc, identity)) {
52
52
  throw new PermissionActionError({
53
53
  documentId,
54
54
  transactionId,
@@ -56,7 +56,7 @@ export function handleUnpublish(
56
56
  })
57
57
  }
58
58
 
59
- if (!working[draftId] && !checkGrant(grants.create, newDraftFromPublished)) {
59
+ if (!working[draftId] && !checkGrant(grants.create, newDraftFromPublished, identity)) {
60
60
  throw new PermissionActionError({
61
61
  documentId,
62
62
  transactionId,
@@ -19,7 +19,7 @@ const EMPTY_REVISIONS: NonNullable<Required<DocumentState['unverifiedRevisions']
19
19
 
20
20
  export type SyncTransactionState = Pick<
21
21
  DocumentStoreState,
22
- 'queued' | 'applied' | 'documentStates' | 'outgoing' | 'grants'
22
+ 'queued' | 'applied' | 'documentStates' | 'outgoing' | 'grants' | 'identity'
23
23
  >
24
24
 
25
25
  type DocumentHandleLike = Pick<DocumentHandle, 'perspective'> & {
@@ -232,6 +232,7 @@ export function applyFirstQueuedTransaction(prev: SyncTransactionState): SyncTra
232
232
  base: working,
233
233
  timestamp,
234
234
  grants: prev.grants,
235
+ identity: prev.identity,
235
236
  })
236
237
  const applied: AppliedTransaction = {
237
238
  ...queued,
@@ -399,7 +400,7 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
399
400
 
400
401
  for (const t of prev.applied) {
401
402
  try {
402
- const next = processActions({...t, working, grants: prev.grants})
403
+ const next = processActions({...t, working, grants: prev.grants, identity: prev.identity})
403
404
  working = next.working
404
405
  nextApplied.push({...t, ...next})
405
406
  } catch (error) {
@@ -506,7 +507,7 @@ export function applyRemoteDocument(
506
507
  // transaction again through the listener and this same flow will run then
507
508
  for (const curr of prev.applied) {
508
509
  try {
509
- const next = processActions({...curr, working, grants: prev.grants})
510
+ const next = processActions({...curr, working, grants: prev.grants, identity: prev.identity})
510
511
  working = next.working
511
512
  // next includes an updated `previous` set and `working` set and updates
512
513
  // the `outgoingAction` and `outgoingMutations`. the `base` set from the
@@ -0,0 +1,178 @@
1
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {type EditDocumentAction} from './actions'
4
+ import {getEffectiveDocumentModel, normalizeActionsForResource} from './resourceRules'
5
+
6
+ const datasetResource = {projectId: 'p', dataset: 'd'}
7
+ const canvasResource = {canvasId: 'canvas-1'}
8
+ const mediaLibraryResource = {mediaLibraryId: 'ml-1'}
9
+
10
+ function editAction(
11
+ overrides: Partial<EditDocumentAction> & Pick<EditDocumentAction, 'documentId' | 'documentType'>,
12
+ ): EditDocumentAction {
13
+ return {
14
+ type: 'document.edit',
15
+ patches: [{set: {foo: 'bar'}}],
16
+ ...overrides,
17
+ }
18
+ }
19
+
20
+ describe('getEffectiveDocumentModel', () => {
21
+ it('returns passthrough for no resource', () => {
22
+ expect(getEffectiveDocumentModel(undefined, 'anything')).toEqual({
23
+ liveEdit: undefined,
24
+ supportsReleases: true,
25
+ })
26
+ })
27
+
28
+ it('returns passthrough for dataset resources', () => {
29
+ expect(getEffectiveDocumentModel(datasetResource, 'author')).toEqual({
30
+ liveEdit: undefined,
31
+ supportsReleases: true,
32
+ })
33
+ })
34
+
35
+ it('forces liveEdit and disallows releases for canvas resources', () => {
36
+ expect(getEffectiveDocumentModel(canvasResource, 'page')).toEqual({
37
+ liveEdit: true,
38
+ supportsReleases: false,
39
+ })
40
+ })
41
+
42
+ it('forces liveEdit for non-asset media library types', () => {
43
+ expect(getEffectiveDocumentModel(mediaLibraryResource, 'sanity.imageAsset')).toEqual({
44
+ liveEdit: true,
45
+ supportsReleases: false,
46
+ })
47
+ })
48
+
49
+ it('keeps draft/published model for sanity.asset in media library', () => {
50
+ expect(getEffectiveDocumentModel(mediaLibraryResource, 'sanity.asset')).toEqual({
51
+ liveEdit: false,
52
+ supportsReleases: false,
53
+ })
54
+ })
55
+ })
56
+
57
+ describe('normalizeActionsForResource', () => {
58
+ let warnSpy: ReturnType<typeof vi.spyOn>
59
+
60
+ beforeEach(() => {
61
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
62
+ })
63
+
64
+ afterEach(() => {
65
+ warnSpy.mockRestore()
66
+ })
67
+
68
+ it('leaves dataset edits with a release perspective alone', () => {
69
+ const action = editAction({
70
+ documentId: 'versions.relA.doc1',
71
+ documentType: 'author',
72
+ perspective: {releaseName: 'relA'},
73
+ })
74
+
75
+ const result = normalizeActionsForResource([action], datasetResource)
76
+
77
+ expect(result[0]).toBe(action)
78
+ expect(warnSpy).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('strips release perspective and forces liveEdit for canvas, warning once', () => {
82
+ const action = editAction({
83
+ documentId: 'versions.relA.doc1',
84
+ documentType: 'page',
85
+ perspective: {releaseName: 'relA'},
86
+ })
87
+
88
+ const [result] = normalizeActionsForResource([action], canvasResource) as [EditDocumentAction]
89
+
90
+ expect(result.liveEdit).toBe(true)
91
+ expect(result.perspective).toBeUndefined()
92
+ expect(result.documentId).toBe('doc1')
93
+ expect(warnSpy).toHaveBeenCalledTimes(1)
94
+ expect(warnSpy.mock.calls[0][0]).toContain('Canvas')
95
+ expect(warnSpy.mock.calls[0][0]).toContain('page (doc1)')
96
+ })
97
+
98
+ it('strips release perspective and forces liveEdit for non-asset media library types', () => {
99
+ const action = editAction({
100
+ documentId: 'versions.relA.doc1',
101
+ documentType: 'sanity.imageAsset',
102
+ perspective: {releaseName: 'relA'},
103
+ })
104
+
105
+ const [result] = normalizeActionsForResource([action], mediaLibraryResource) as [
106
+ EditDocumentAction,
107
+ ]
108
+
109
+ expect(result.liveEdit).toBe(true)
110
+ expect(result.perspective).toBeUndefined()
111
+ expect(result.documentId).toBe('doc1')
112
+ expect(warnSpy).toHaveBeenCalledTimes(1)
113
+ expect(warnSpy.mock.calls[0][0]).toContain('Media Library')
114
+ })
115
+
116
+ it('strips release perspective but keeps draft/publish model for sanity.asset', () => {
117
+ const action = editAction({
118
+ documentId: 'versions.relA.doc1',
119
+ documentType: 'sanity.asset',
120
+ perspective: {releaseName: 'relA'},
121
+ })
122
+
123
+ const [result] = normalizeActionsForResource([action], mediaLibraryResource) as [
124
+ EditDocumentAction,
125
+ ]
126
+
127
+ expect(result.liveEdit).toBeUndefined()
128
+ expect(result.perspective).toBeUndefined()
129
+ expect(result.documentId).toBe('doc1')
130
+ expect(warnSpy).toHaveBeenCalledTimes(1)
131
+ })
132
+
133
+ it('silently forces liveEdit for canvas with no release perspective', () => {
134
+ const action = editAction({
135
+ documentId: 'drafts.doc1',
136
+ documentType: 'page',
137
+ perspective: 'drafts',
138
+ })
139
+
140
+ const [result] = normalizeActionsForResource([action], canvasResource) as [EditDocumentAction]
141
+
142
+ expect(result.liveEdit).toBe(true)
143
+ expect(result.documentId).toBe('doc1')
144
+ expect(warnSpy).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('emits a single warning for multiple stripped actions in one call', () => {
148
+ const a = editAction({
149
+ documentId: 'versions.relA.doc1',
150
+ documentType: 'page',
151
+ perspective: {releaseName: 'relA'},
152
+ })
153
+ const b = editAction({
154
+ documentId: 'versions.relA.doc2',
155
+ documentType: 'page',
156
+ perspective: {releaseName: 'relA'},
157
+ })
158
+
159
+ normalizeActionsForResource([a, b], canvasResource)
160
+
161
+ expect(warnSpy).toHaveBeenCalledTimes(1)
162
+ expect(warnSpy.mock.calls[0][0]).toContain('doc1')
163
+ expect(warnSpy.mock.calls[0][0]).toContain('doc2')
164
+ })
165
+
166
+ it('passes non-edit actions through unchanged', () => {
167
+ const action = {
168
+ type: 'document.publish' as const,
169
+ documentId: 'doc1',
170
+ documentType: 'page',
171
+ }
172
+
173
+ const result = normalizeActionsForResource([action], canvasResource)
174
+
175
+ expect(result[0]).toBe(action)
176
+ expect(warnSpy).not.toHaveBeenCalled()
177
+ })
178
+ })
@@ -0,0 +1,117 @@
1
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
+
3
+ import {
4
+ type DocumentResource,
5
+ isCanvasResource,
6
+ isMediaLibraryResource,
7
+ } from '../config/sanityConfig'
8
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
9
+ import {type Action, type DocumentAction} from './actions'
10
+ import {getEffectiveDocumentId} from './util'
11
+
12
+ export interface EffectiveDocModel {
13
+ /**
14
+ * If this is `undefined`, the resource has no opinion and the handle's `liveEdit`
15
+ * flag should be respected.
16
+ */
17
+ liveEdit: boolean | undefined
18
+ /**
19
+ * When `false`, the resource does not support release perspectives. Callers
20
+ * should drop any release perspective and fall back to the standard path.
21
+ */
22
+ supportsReleases: boolean
23
+ }
24
+
25
+ const MEDIA_LIBRARY_DRAFTED_TYPES = new Set(['sanity.asset'])
26
+
27
+ /**
28
+ * Different resources have different "default" editing models.
29
+ *
30
+ * Canvas uses a liveEdit model.
31
+ * Medial Library is mostly liveEdit except for `sanity.asset`, which retains the
32
+ * draft/published model. Neither resource supports release perspectives.
33
+ */
34
+ export function getEffectiveDocumentModel(
35
+ resource: DocumentResource | undefined,
36
+ documentType: string | undefined,
37
+ ): EffectiveDocModel {
38
+ if (!resource) {
39
+ return {liveEdit: undefined, supportsReleases: true}
40
+ }
41
+ if (isCanvasResource(resource)) {
42
+ return {liveEdit: true, supportsReleases: false}
43
+ }
44
+ if (isMediaLibraryResource(resource)) {
45
+ const isDrafted = documentType ? MEDIA_LIBRARY_DRAFTED_TYPES.has(documentType) : false
46
+ return {liveEdit: !isDrafted, supportsReleases: false}
47
+ }
48
+ return {liveEdit: undefined, supportsReleases: true}
49
+ }
50
+
51
+ function describeResource(resource: DocumentResource | undefined): string {
52
+ if (resource && isCanvasResource(resource)) return 'Canvas'
53
+ if (resource && isMediaLibraryResource(resource)) return 'Media Library'
54
+ return 'this resource'
55
+ }
56
+
57
+ /**
58
+ * Rewrites edit actions to match the editing model of the bound resource.
59
+ *
60
+ * Canvas and Media Library resources do not support release perspectives, and
61
+ * Canvas (plus most Media Library types) does not have a draft/published
62
+ * model. When an edit action arrives with a release perspective for a resource
63
+ * that doesn't support it, the perspective is stripped and a single warning
64
+ * is logged. When the resource forces liveEdit, the action's `liveEdit` flag
65
+ * is set so the dispatcher takes the liveEdit branch.
66
+ *
67
+ * Only `document.edit` actions are normalized today — other action types
68
+ * (publish, unpublish, release actions, etc.) pass through unchanged.
69
+ *
70
+ * @internal
71
+ */
72
+ export function normalizeActionsForResource(
73
+ actions: Action[],
74
+ resource: DocumentResource | undefined,
75
+ ): Action[] {
76
+ // collect actions that may have changed in unexpected ways
77
+ const stripped: Array<{documentType: string; documentId: string}> = []
78
+
79
+ const normalized = actions.map((action) => {
80
+ if (action.type !== 'document.edit') return action
81
+
82
+ const {liveEdit: forcedLiveEdit, supportsReleases} = getEffectiveDocumentModel(
83
+ resource,
84
+ action.documentType,
85
+ )
86
+ const shouldRemovePerspective = isReleasePerspective(action.perspective) && !supportsReleases
87
+ const shouldForceLiveEdit = forcedLiveEdit === true && !action.liveEdit
88
+
89
+ if (!shouldRemovePerspective && !shouldForceLiveEdit) return action
90
+
91
+ const corrected: DocumentAction = {...action}
92
+ if (shouldForceLiveEdit) corrected.liveEdit = true
93
+ if (shouldRemovePerspective) corrected.perspective = undefined
94
+
95
+ // ensure we're using the right document ID for the corrected model
96
+ corrected.documentId = getEffectiveDocumentId({
97
+ ...corrected,
98
+ documentId: getPublishedId(DocumentId(corrected.documentId)),
99
+ })
100
+
101
+ if (shouldRemovePerspective) {
102
+ stripped.push({documentType: action.documentType, documentId: corrected.documentId})
103
+ }
104
+
105
+ return corrected
106
+ })
107
+
108
+ if (stripped.length > 0) {
109
+ const docs = stripped.map((e) => `${e.documentType} (${e.documentId})`).join(', ')
110
+ // eslint-disable-next-line no-console
111
+ console.warn(
112
+ `[sanity-sdk] ${describeResource(resource)} does not support release perspectives — falling back to the standard editing path for: ${docs}`,
113
+ )
114
+ }
115
+
116
+ return normalized
117
+ }