@sanity/sdk 2.11.0 → 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.
- package/dist/_chunks-dts/utils.d.ts +171 -19
- package/dist/_chunks-es/_internal.js +41 -26
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +25 -9
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/telemetryManager.js +25 -19
- package/dist/_chunks-es/telemetryManager.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/_exports/_internal.d.ts +27 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +723 -418
- package/dist/index.js.map +1 -1
- package/package.json +16 -16
- package/src/_exports/index.ts +23 -2
- package/src/auth/refreshStampedToken.test.ts +2 -2
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +116 -0
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +27 -9
- package/src/config/sanityConfig.ts +12 -0
- package/src/document/actions.test.ts +112 -1
- package/src/document/actions.ts +148 -1
- package/src/document/applyDocumentActions.ts +4 -3
- package/src/document/documentStore.ts +7 -6
- package/src/document/events.test.ts +57 -2
- package/src/document/events.ts +43 -24
- package/src/document/permissions.ts +1 -1
- package/src/document/processActions/create.ts +135 -0
- package/src/document/processActions/delete.ts +100 -0
- package/src/document/processActions/discard.ts +63 -0
- package/src/document/processActions/edit.ts +141 -0
- package/src/document/processActions/processActions.ts +209 -0
- package/src/document/processActions/publish.ts +120 -0
- package/src/document/processActions/releaseArchive.ts +77 -0
- package/src/document/processActions/releaseCreate.ts +59 -0
- package/src/document/processActions/releaseDelete.ts +65 -0
- package/src/document/processActions/releaseEdit.ts +36 -0
- package/src/document/processActions/releasePublish.ts +45 -0
- package/src/document/processActions/releaseSchedule.ts +87 -0
- package/src/document/processActions/releaseUtil.ts +31 -0
- package/src/document/processActions/shared.ts +139 -0
- package/src/document/processActions/unpublish.ts +85 -0
- package/src/document/processActions.test.ts +424 -2
- package/src/document/reducers.ts +41 -6
- package/src/releases/getPerspectiveState.test.ts +1 -1
- package/src/releases/releasesStore.test.ts +50 -1
- package/src/releases/releasesStore.ts +41 -18
- package/src/releases/utils/sortReleases.test.ts +2 -2
- package/src/releases/utils/sortReleases.ts +1 -1
- package/src/telemetry/environment.test.ts +119 -0
- package/src/telemetry/environment.ts +92 -0
- package/src/telemetry/{__telemetry__/sdk.telemetry.ts → events.ts} +9 -9
- package/src/telemetry/initTelemetry.test.ts +240 -16
- package/src/telemetry/initTelemetry.ts +39 -16
- package/src/telemetry/telemetryManager.test.ts +129 -65
- package/src/telemetry/telemetryManager.ts +41 -29
- package/src/document/processActions.ts +0 -735
- package/src/telemetry/devMode.test.ts +0 -60
- package/src/telemetry/devMode.ts +0 -41
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {type Action, type ReleaseAction} from '../actions'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Path prefix shared by every release document. Matches studio's
|
|
5
|
+
* `RELEASE_DOCUMENTS_PATH` constant.
|
|
6
|
+
*/
|
|
7
|
+
const RELEASE_DOCUMENTS_PATH = '_.releases'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the full release document ID for the given release name.
|
|
11
|
+
* e.g. `getReleaseDocumentId('my-release') === '_.releases.my-release'`
|
|
12
|
+
* @beta
|
|
13
|
+
*/
|
|
14
|
+
export function getReleaseDocumentId(releaseId: string): string {
|
|
15
|
+
return `${RELEASE_DOCUMENTS_PATH}.${releaseId}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const RELEASE_ACTION_TYPES = new Set([
|
|
19
|
+
'release.create',
|
|
20
|
+
'release.edit',
|
|
21
|
+
'release.publish',
|
|
22
|
+
'release.schedule',
|
|
23
|
+
'release.unschedule',
|
|
24
|
+
'release.archive',
|
|
25
|
+
'release.unarchive',
|
|
26
|
+
'release.delete',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
export function isReleaseAction(action: Action): action is ReleaseAction {
|
|
30
|
+
return RELEASE_ACTION_TYPES.has(action.type)
|
|
31
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {diffValue} from '@sanity/diff-patch'
|
|
2
|
+
import {type Mutation, type PatchOperations, type SanityDocument} from '@sanity/types'
|
|
3
|
+
import {evaluateSync, type ExprNode} from 'groq-js'
|
|
4
|
+
|
|
5
|
+
import {type Grant} from '../permissions'
|
|
6
|
+
import {type DocumentSet, processMutations} from '../processMutations'
|
|
7
|
+
import {type HttpAction} from '../reducers'
|
|
8
|
+
|
|
9
|
+
export interface ActionHandlerContext {
|
|
10
|
+
base: DocumentSet
|
|
11
|
+
working: DocumentSet
|
|
12
|
+
transactionId: string
|
|
13
|
+
timestamp: string
|
|
14
|
+
grants: Record<Grant, ExprNode>
|
|
15
|
+
outgoingActions: HttpAction[]
|
|
16
|
+
outgoingMutations: Mutation[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ActionHandlerResult {
|
|
20
|
+
base: DocumentSet
|
|
21
|
+
working: DocumentSet
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean {
|
|
25
|
+
const value = evaluateSync(grantExpr, {params: {document}})
|
|
26
|
+
return value.type === 'boolean' && value.data
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ActionErrorOptions {
|
|
30
|
+
message: string
|
|
31
|
+
documentId: string
|
|
32
|
+
transactionId: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Thrown when a precondition for an action failed.
|
|
37
|
+
*/
|
|
38
|
+
export class ActionError extends Error implements ActionErrorOptions {
|
|
39
|
+
documentId!: string
|
|
40
|
+
transactionId!: string
|
|
41
|
+
|
|
42
|
+
constructor(options: ActionErrorOptions) {
|
|
43
|
+
super(options.message)
|
|
44
|
+
Object.assign(this, options)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class PermissionActionError extends ActionError {}
|
|
49
|
+
|
|
50
|
+
interface ApplySingleDocPatchOptions {
|
|
51
|
+
base: DocumentSet
|
|
52
|
+
working: DocumentSet
|
|
53
|
+
documentId: string
|
|
54
|
+
patches: PatchOperations[] | undefined
|
|
55
|
+
transactionId: string
|
|
56
|
+
timestamp: string
|
|
57
|
+
grants: Record<Grant, ExprNode>
|
|
58
|
+
/**
|
|
59
|
+
* Error message thrown when the target document does not exist in either
|
|
60
|
+
* the base or working set.
|
|
61
|
+
*/
|
|
62
|
+
notFoundMessage?: string
|
|
63
|
+
/**
|
|
64
|
+
* Error message thrown when the working document fails the `update` grant.
|
|
65
|
+
*/
|
|
66
|
+
permissionMessage?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ApplySingleDocPatchResult {
|
|
70
|
+
base: DocumentSet
|
|
71
|
+
working: DocumentSet
|
|
72
|
+
/**
|
|
73
|
+
* Patch operations representing the minimal diff between base before and
|
|
74
|
+
* after the user's patches were applied. These are the patches that should
|
|
75
|
+
* be sent to the server (or applied to the working set as mutations).
|
|
76
|
+
*/
|
|
77
|
+
diffedPatches: PatchOperations[]
|
|
78
|
+
/**
|
|
79
|
+
* Mutation envelopes for `diffedPatches` already keyed to `documentId`.
|
|
80
|
+
* Useful for callers that want to push them to `outgoingMutations`.
|
|
81
|
+
*/
|
|
82
|
+
workingMutations: Mutation[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Shared logic for applying user-provided patches to a single document that
|
|
87
|
+
* is identified by an exact ID (no draft/published wrapping). Used by the
|
|
88
|
+
* liveEdit branch of `document.edit` and by `release.edit`.
|
|
89
|
+
*
|
|
90
|
+
* Returns the updated base + working sets, plus the diffed patches in both
|
|
91
|
+
* raw and mutation form so the caller can decide what to send to the server.
|
|
92
|
+
*/
|
|
93
|
+
export function applySingleDocPatch({
|
|
94
|
+
base: initialBase,
|
|
95
|
+
working: initialWorking,
|
|
96
|
+
documentId,
|
|
97
|
+
patches,
|
|
98
|
+
transactionId,
|
|
99
|
+
timestamp,
|
|
100
|
+
grants,
|
|
101
|
+
notFoundMessage = 'Cannot edit document because it does not exist.',
|
|
102
|
+
permissionMessage = `You do not have permission to edit document "${documentId}".`,
|
|
103
|
+
}: ApplySingleDocPatchOptions): ApplySingleDocPatchResult {
|
|
104
|
+
let base = initialBase
|
|
105
|
+
let working = initialWorking
|
|
106
|
+
|
|
107
|
+
const userPatches = patches?.map((patch) => ({patch: {id: documentId, ...patch}}))
|
|
108
|
+
|
|
109
|
+
if (!userPatches?.length) {
|
|
110
|
+
return {base, working, diffedPatches: [], workingMutations: []}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!working[documentId] || !base[documentId]) {
|
|
114
|
+
throw new ActionError({documentId, transactionId, message: notFoundMessage})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const baseBefore = base[documentId]
|
|
118
|
+
base = processMutations({documents: base, transactionId, mutations: userPatches, timestamp})
|
|
119
|
+
const baseAfter = base[documentId]
|
|
120
|
+
const diffedPatches = diffValue(baseBefore, baseAfter) as PatchOperations[]
|
|
121
|
+
|
|
122
|
+
const workingBefore = working[documentId] as SanityDocument
|
|
123
|
+
if (!checkGrant(grants.update, workingBefore)) {
|
|
124
|
+
throw new PermissionActionError({documentId, transactionId, message: permissionMessage})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const workingMutations: Mutation[] = diffedPatches.map((patch) => ({
|
|
128
|
+
patch: {id: documentId, ...patch},
|
|
129
|
+
}))
|
|
130
|
+
|
|
131
|
+
working = processMutations({
|
|
132
|
+
documents: working,
|
|
133
|
+
transactionId,
|
|
134
|
+
mutations: workingMutations,
|
|
135
|
+
timestamp,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return {base, working, diffedPatches, workingMutations}
|
|
139
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
|
|
2
|
+
import {type Mutation, type SanityDocument} from '@sanity/types'
|
|
3
|
+
|
|
4
|
+
import {isReleasePerspective} from '../../releases/utils/isReleasePerspective'
|
|
5
|
+
import {type UnpublishDocumentAction} from '../actions'
|
|
6
|
+
import {getId, processMutations} from '../processMutations'
|
|
7
|
+
import {
|
|
8
|
+
ActionError,
|
|
9
|
+
type ActionHandlerContext,
|
|
10
|
+
type ActionHandlerResult,
|
|
11
|
+
checkGrant,
|
|
12
|
+
PermissionActionError,
|
|
13
|
+
} from './shared'
|
|
14
|
+
|
|
15
|
+
export function handleUnpublish(
|
|
16
|
+
action: UnpublishDocumentAction,
|
|
17
|
+
ctx: ActionHandlerContext,
|
|
18
|
+
): ActionHandlerResult {
|
|
19
|
+
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
|
|
20
|
+
let {base, working} = ctx
|
|
21
|
+
|
|
22
|
+
const documentId = getId(action.documentId)
|
|
23
|
+
|
|
24
|
+
if (action.liveEdit || isReleasePerspective(action.perspective)) {
|
|
25
|
+
throw new ActionError({
|
|
26
|
+
documentId,
|
|
27
|
+
transactionId,
|
|
28
|
+
message: `Cannot unpublish this document. Unpublishing is not supported for liveEdit or version (release) documents.`,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Standard draft/published or version logic
|
|
33
|
+
const draftId = getDraftId(DocumentId(documentId))
|
|
34
|
+
const publishedId = getPublishedId(DocumentId(documentId))
|
|
35
|
+
|
|
36
|
+
if (!working[publishedId] && !base[publishedId]) {
|
|
37
|
+
throw new ActionError({
|
|
38
|
+
documentId,
|
|
39
|
+
transactionId,
|
|
40
|
+
message: `Cannot unpublish because the document "${documentId}" is not currently published.`,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sourceDoc = working[publishedId] ?? (base[publishedId] as SanityDocument)
|
|
45
|
+
const newDraftFromPublished = {...sourceDoc, _id: draftId}
|
|
46
|
+
const mutations: Mutation[] = [
|
|
47
|
+
{delete: {id: publishedId}},
|
|
48
|
+
{createIfNotExists: newDraftFromPublished},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
if (!checkGrant(grants.update, sourceDoc)) {
|
|
52
|
+
throw new PermissionActionError({
|
|
53
|
+
documentId,
|
|
54
|
+
transactionId,
|
|
55
|
+
message: `You do not have permission to unpublish the document "${documentId}".`,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!working[draftId] && !checkGrant(grants.create, newDraftFromPublished)) {
|
|
60
|
+
throw new PermissionActionError({
|
|
61
|
+
documentId,
|
|
62
|
+
transactionId,
|
|
63
|
+
message: `You do not have permission to create a draft from the published version of "${documentId}".`,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
base = processMutations({
|
|
68
|
+
documents: base,
|
|
69
|
+
transactionId,
|
|
70
|
+
mutations: [
|
|
71
|
+
{delete: {id: publishedId}},
|
|
72
|
+
{createIfNotExists: {...(base[publishedId] ?? sourceDoc), _id: draftId}},
|
|
73
|
+
],
|
|
74
|
+
timestamp,
|
|
75
|
+
})
|
|
76
|
+
working = processMutations({documents: working, transactionId, mutations, timestamp})
|
|
77
|
+
|
|
78
|
+
outgoingMutations.push(...mutations)
|
|
79
|
+
outgoingActions.push({
|
|
80
|
+
actionType: 'sanity.action.document.unpublish',
|
|
81
|
+
draftId,
|
|
82
|
+
publishedId,
|
|
83
|
+
})
|
|
84
|
+
return {base, working}
|
|
85
|
+
}
|
|
@@ -2,8 +2,8 @@ import {type CreateMutation, type Reference, type SanityDocument} from '@sanity/
|
|
|
2
2
|
import {parse} from 'groq-js'
|
|
3
3
|
import {describe, expect, it} from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {type DocumentAction} from './actions'
|
|
6
|
-
import {ActionError, processActions} from './processActions'
|
|
5
|
+
import {type Action, type DocumentAction} from './actions'
|
|
6
|
+
import {ActionError, processActions} from './processActions/processActions'
|
|
7
7
|
import {type DocumentSet} from './processMutations'
|
|
8
8
|
|
|
9
9
|
// Helper: Create a sample document that conforms to SanityDocument.
|
|
@@ -1420,4 +1420,426 @@ describe('processActions', () => {
|
|
|
1420
1420
|
})
|
|
1421
1421
|
})
|
|
1422
1422
|
})
|
|
1423
|
+
|
|
1424
|
+
describe('release actions', () => {
|
|
1425
|
+
const releaseName = 'my-release'
|
|
1426
|
+
const releaseDocId = `_.releases.${releaseName}`
|
|
1427
|
+
|
|
1428
|
+
const createReleaseDoc = (overrides: Partial<SanityDocument> = {}): SanityDocument => ({
|
|
1429
|
+
_id: releaseDocId,
|
|
1430
|
+
_type: 'system.release',
|
|
1431
|
+
_createdAt: '2025-01-01T00:00:00.000Z',
|
|
1432
|
+
_updatedAt: '2025-01-01T00:00:00.000Z',
|
|
1433
|
+
_rev: 'initial',
|
|
1434
|
+
name: releaseName,
|
|
1435
|
+
state: 'active',
|
|
1436
|
+
metadata: {releaseType: 'undecided'},
|
|
1437
|
+
...overrides,
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
describe('release.create', () => {
|
|
1441
|
+
it('inserts an optimistic release doc and emits a release.create action', () => {
|
|
1442
|
+
const base: DocumentSet = {}
|
|
1443
|
+
const working: DocumentSet = {}
|
|
1444
|
+
const actions: Action[] = [
|
|
1445
|
+
{
|
|
1446
|
+
type: 'release.create',
|
|
1447
|
+
releaseId: releaseName,
|
|
1448
|
+
metadata: {title: 'My release', releaseType: 'asap'},
|
|
1449
|
+
},
|
|
1450
|
+
]
|
|
1451
|
+
|
|
1452
|
+
const result = processActions({
|
|
1453
|
+
actions,
|
|
1454
|
+
transactionId,
|
|
1455
|
+
base,
|
|
1456
|
+
working,
|
|
1457
|
+
timestamp,
|
|
1458
|
+
grants: defaultGrants,
|
|
1459
|
+
})
|
|
1460
|
+
|
|
1461
|
+
const doc = result.working[releaseDocId]
|
|
1462
|
+
expect(doc).toBeDefined()
|
|
1463
|
+
expect(doc?._type).toBe('system.release')
|
|
1464
|
+
expect(doc?.['name']).toBe(releaseName)
|
|
1465
|
+
expect(doc?.['state']).toBe('active')
|
|
1466
|
+
expect(doc?.['metadata']).toEqual({title: 'My release', releaseType: 'asap'})
|
|
1467
|
+
|
|
1468
|
+
expect(result.outgoingActions).toEqual([
|
|
1469
|
+
{
|
|
1470
|
+
actionType: 'sanity.action.release.create',
|
|
1471
|
+
releaseId: releaseName,
|
|
1472
|
+
metadata: {title: 'My release', releaseType: 'asap'},
|
|
1473
|
+
},
|
|
1474
|
+
])
|
|
1475
|
+
})
|
|
1476
|
+
|
|
1477
|
+
it('throws if the release already exists in base or working', () => {
|
|
1478
|
+
const existing = createReleaseDoc()
|
|
1479
|
+
expect(() =>
|
|
1480
|
+
processActions({
|
|
1481
|
+
actions: [
|
|
1482
|
+
{
|
|
1483
|
+
type: 'release.create',
|
|
1484
|
+
releaseId: releaseName,
|
|
1485
|
+
metadata: {releaseType: 'undecided'},
|
|
1486
|
+
},
|
|
1487
|
+
],
|
|
1488
|
+
transactionId,
|
|
1489
|
+
base: {[releaseDocId]: existing},
|
|
1490
|
+
working: {[releaseDocId]: existing},
|
|
1491
|
+
timestamp,
|
|
1492
|
+
grants: defaultGrants,
|
|
1493
|
+
}),
|
|
1494
|
+
).toThrow(/already exists/)
|
|
1495
|
+
})
|
|
1496
|
+
|
|
1497
|
+
it('throws PermissionActionError when the create grant is denied', () => {
|
|
1498
|
+
expect(() =>
|
|
1499
|
+
processActions({
|
|
1500
|
+
actions: [
|
|
1501
|
+
{
|
|
1502
|
+
type: 'release.create',
|
|
1503
|
+
releaseId: releaseName,
|
|
1504
|
+
metadata: {releaseType: 'undecided'},
|
|
1505
|
+
},
|
|
1506
|
+
],
|
|
1507
|
+
transactionId,
|
|
1508
|
+
base: {},
|
|
1509
|
+
working: {},
|
|
1510
|
+
timestamp,
|
|
1511
|
+
grants: {...defaultGrants, create: alwaysDeny},
|
|
1512
|
+
}),
|
|
1513
|
+
).toThrow(/permission to create release/)
|
|
1514
|
+
})
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
describe('release.edit', () => {
|
|
1518
|
+
it('applies the patch optimistically and forwards the original patch to the outgoing action', () => {
|
|
1519
|
+
const existing = createReleaseDoc({
|
|
1520
|
+
metadata: {releaseType: 'undecided'},
|
|
1521
|
+
} as Partial<SanityDocument>)
|
|
1522
|
+
const patch = {set: {'metadata.title': 'Updated title'}}
|
|
1523
|
+
|
|
1524
|
+
const result = processActions({
|
|
1525
|
+
actions: [{type: 'release.edit', releaseId: releaseName, patch}],
|
|
1526
|
+
transactionId,
|
|
1527
|
+
base: {[releaseDocId]: existing},
|
|
1528
|
+
working: {[releaseDocId]: existing},
|
|
1529
|
+
timestamp,
|
|
1530
|
+
grants: defaultGrants,
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
const updated = result.working[releaseDocId] as SanityDocument & {
|
|
1534
|
+
metadata: {title?: string; releaseType: string}
|
|
1535
|
+
}
|
|
1536
|
+
expect(updated.metadata.title).toBe('Updated title')
|
|
1537
|
+
// releaseType wasn't unset
|
|
1538
|
+
expect(updated.metadata.releaseType).toBe('undecided')
|
|
1539
|
+
|
|
1540
|
+
expect(result.outgoingActions).toEqual([
|
|
1541
|
+
{
|
|
1542
|
+
actionType: 'sanity.action.release.edit',
|
|
1543
|
+
releaseId: releaseName,
|
|
1544
|
+
patch,
|
|
1545
|
+
},
|
|
1546
|
+
])
|
|
1547
|
+
})
|
|
1548
|
+
|
|
1549
|
+
it('throws when editing a release that does not exist', () => {
|
|
1550
|
+
expect(() =>
|
|
1551
|
+
processActions({
|
|
1552
|
+
actions: [
|
|
1553
|
+
{
|
|
1554
|
+
type: 'release.edit',
|
|
1555
|
+
releaseId: releaseName,
|
|
1556
|
+
patch: {set: {'metadata.title': 'x'}},
|
|
1557
|
+
},
|
|
1558
|
+
],
|
|
1559
|
+
transactionId,
|
|
1560
|
+
base: {},
|
|
1561
|
+
working: {},
|
|
1562
|
+
timestamp,
|
|
1563
|
+
grants: defaultGrants,
|
|
1564
|
+
}),
|
|
1565
|
+
).toThrow(/does not exist/)
|
|
1566
|
+
})
|
|
1567
|
+
|
|
1568
|
+
it('throws PermissionActionError when the working release fails the update grant', () => {
|
|
1569
|
+
const existing = createReleaseDoc()
|
|
1570
|
+
expect(() =>
|
|
1571
|
+
processActions({
|
|
1572
|
+
actions: [
|
|
1573
|
+
{
|
|
1574
|
+
type: 'release.edit',
|
|
1575
|
+
releaseId: releaseName,
|
|
1576
|
+
patch: {set: {'metadata.title': 'x'}},
|
|
1577
|
+
},
|
|
1578
|
+
],
|
|
1579
|
+
transactionId,
|
|
1580
|
+
base: {[releaseDocId]: existing},
|
|
1581
|
+
working: {[releaseDocId]: existing},
|
|
1582
|
+
timestamp,
|
|
1583
|
+
grants: {...defaultGrants, update: alwaysDeny},
|
|
1584
|
+
}),
|
|
1585
|
+
).toThrow(/permission to edit release/)
|
|
1586
|
+
})
|
|
1587
|
+
})
|
|
1588
|
+
|
|
1589
|
+
describe('fire-and-forget actions', () => {
|
|
1590
|
+
const existing = createReleaseDoc()
|
|
1591
|
+
const cases: Array<{
|
|
1592
|
+
name: string
|
|
1593
|
+
action: Action
|
|
1594
|
+
expectedOutgoing: Record<string, unknown>
|
|
1595
|
+
}> = [
|
|
1596
|
+
{
|
|
1597
|
+
name: 'release.publish',
|
|
1598
|
+
action: {type: 'release.publish', releaseId: releaseName},
|
|
1599
|
+
expectedOutgoing: {actionType: 'sanity.action.release.publish', releaseId: releaseName},
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
name: 'release.schedule',
|
|
1603
|
+
action: {
|
|
1604
|
+
type: 'release.schedule',
|
|
1605
|
+
releaseId: releaseName,
|
|
1606
|
+
publishAt: '2026-01-01T00:00:00.000Z',
|
|
1607
|
+
},
|
|
1608
|
+
expectedOutgoing: {
|
|
1609
|
+
actionType: 'sanity.action.release.schedule',
|
|
1610
|
+
releaseId: releaseName,
|
|
1611
|
+
publishAt: '2026-01-01T00:00:00.000Z',
|
|
1612
|
+
},
|
|
1613
|
+
},
|
|
1614
|
+
{
|
|
1615
|
+
name: 'release.unschedule',
|
|
1616
|
+
action: {type: 'release.unschedule', releaseId: releaseName},
|
|
1617
|
+
expectedOutgoing: {
|
|
1618
|
+
actionType: 'sanity.action.release.unschedule',
|
|
1619
|
+
releaseId: releaseName,
|
|
1620
|
+
},
|
|
1621
|
+
},
|
|
1622
|
+
{
|
|
1623
|
+
name: 'release.archive',
|
|
1624
|
+
action: {type: 'release.archive', releaseId: releaseName},
|
|
1625
|
+
expectedOutgoing: {actionType: 'sanity.action.release.archive', releaseId: releaseName},
|
|
1626
|
+
},
|
|
1627
|
+
{
|
|
1628
|
+
name: 'release.unarchive',
|
|
1629
|
+
action: {type: 'release.unarchive', releaseId: releaseName},
|
|
1630
|
+
expectedOutgoing: {actionType: 'sanity.action.release.unarchive', releaseId: releaseName},
|
|
1631
|
+
},
|
|
1632
|
+
]
|
|
1633
|
+
|
|
1634
|
+
it.each(cases)(
|
|
1635
|
+
'$name does not mutate local state and emits the matching outgoing action',
|
|
1636
|
+
({action, expectedOutgoing}) => {
|
|
1637
|
+
const base: DocumentSet = {[releaseDocId]: existing}
|
|
1638
|
+
const working: DocumentSet = {[releaseDocId]: existing}
|
|
1639
|
+
|
|
1640
|
+
const result = processActions({
|
|
1641
|
+
actions: [action],
|
|
1642
|
+
transactionId,
|
|
1643
|
+
base,
|
|
1644
|
+
working,
|
|
1645
|
+
timestamp,
|
|
1646
|
+
grants: defaultGrants,
|
|
1647
|
+
})
|
|
1648
|
+
|
|
1649
|
+
// working and base reference the same doc instance — no optimistic
|
|
1650
|
+
// mutation was applied
|
|
1651
|
+
expect(result.working[releaseDocId]).toBe(existing)
|
|
1652
|
+
expect(result.outgoingActions).toEqual([expectedOutgoing])
|
|
1653
|
+
expect(result.outgoingMutations).toEqual([])
|
|
1654
|
+
},
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
it.each(cases)('$name throws if the release does not exist', ({action}) => {
|
|
1658
|
+
expect(() =>
|
|
1659
|
+
processActions({
|
|
1660
|
+
actions: [action],
|
|
1661
|
+
transactionId,
|
|
1662
|
+
base: {},
|
|
1663
|
+
working: {},
|
|
1664
|
+
timestamp,
|
|
1665
|
+
grants: defaultGrants,
|
|
1666
|
+
}),
|
|
1667
|
+
).toThrow(ActionError)
|
|
1668
|
+
})
|
|
1669
|
+
|
|
1670
|
+
it.each(cases)(
|
|
1671
|
+
'$name throws PermissionActionError when update grant is denied',
|
|
1672
|
+
({action}) => {
|
|
1673
|
+
const base: DocumentSet = {[releaseDocId]: existing}
|
|
1674
|
+
const working: DocumentSet = {[releaseDocId]: existing}
|
|
1675
|
+
expect(() =>
|
|
1676
|
+
processActions({
|
|
1677
|
+
actions: [action],
|
|
1678
|
+
transactionId,
|
|
1679
|
+
base,
|
|
1680
|
+
working,
|
|
1681
|
+
timestamp,
|
|
1682
|
+
grants: {...defaultGrants, update: alwaysDeny},
|
|
1683
|
+
}),
|
|
1684
|
+
).toThrow(/permission to/)
|
|
1685
|
+
},
|
|
1686
|
+
)
|
|
1687
|
+
})
|
|
1688
|
+
|
|
1689
|
+
describe('release.schedule publishAt validation', () => {
|
|
1690
|
+
const existing = createReleaseDoc()
|
|
1691
|
+
|
|
1692
|
+
it.each(['not-a-date', '', '2026-13-40T99:99:99Z'])(
|
|
1693
|
+
'throws ActionError when publishAt is not a valid timestamp (%s)',
|
|
1694
|
+
(publishAt) => {
|
|
1695
|
+
expect(() =>
|
|
1696
|
+
processActions({
|
|
1697
|
+
actions: [{type: 'release.schedule', releaseId: releaseName, publishAt}],
|
|
1698
|
+
transactionId,
|
|
1699
|
+
base: {[releaseDocId]: existing},
|
|
1700
|
+
working: {[releaseDocId]: existing},
|
|
1701
|
+
timestamp,
|
|
1702
|
+
grants: defaultGrants,
|
|
1703
|
+
}),
|
|
1704
|
+
).toThrow(/must be a valid ISO 8601 timestamp/)
|
|
1705
|
+
},
|
|
1706
|
+
)
|
|
1707
|
+
})
|
|
1708
|
+
|
|
1709
|
+
describe('release.delete', () => {
|
|
1710
|
+
it.each(['archived', 'published'] as const)(
|
|
1711
|
+
'optimistically removes the local release when state is %s',
|
|
1712
|
+
(state) => {
|
|
1713
|
+
const existing = createReleaseDoc({state} as Partial<SanityDocument>)
|
|
1714
|
+
const result = processActions({
|
|
1715
|
+
actions: [{type: 'release.delete', releaseId: releaseName}],
|
|
1716
|
+
transactionId,
|
|
1717
|
+
base: {[releaseDocId]: existing},
|
|
1718
|
+
working: {[releaseDocId]: existing},
|
|
1719
|
+
timestamp,
|
|
1720
|
+
grants: defaultGrants,
|
|
1721
|
+
})
|
|
1722
|
+
|
|
1723
|
+
// processMutations sets deleted entries to null
|
|
1724
|
+
expect(result.working[releaseDocId]).toBeNull()
|
|
1725
|
+
expect(result.outgoingMutations).toEqual([{delete: {id: releaseDocId}}])
|
|
1726
|
+
expect(result.outgoingActions).toEqual([
|
|
1727
|
+
{actionType: 'sanity.action.release.delete', releaseId: releaseName},
|
|
1728
|
+
])
|
|
1729
|
+
},
|
|
1730
|
+
)
|
|
1731
|
+
|
|
1732
|
+
it.each(['active', 'scheduled', 'archiving', 'publishing'] as const)(
|
|
1733
|
+
'throws when state is %s (server only accepts archived/published)',
|
|
1734
|
+
(state) => {
|
|
1735
|
+
const existing = createReleaseDoc({state} as Partial<SanityDocument>)
|
|
1736
|
+
expect(() =>
|
|
1737
|
+
processActions({
|
|
1738
|
+
actions: [{type: 'release.delete', releaseId: releaseName}],
|
|
1739
|
+
transactionId,
|
|
1740
|
+
base: {[releaseDocId]: existing},
|
|
1741
|
+
working: {[releaseDocId]: existing},
|
|
1742
|
+
timestamp,
|
|
1743
|
+
grants: defaultGrants,
|
|
1744
|
+
}),
|
|
1745
|
+
).toThrow(/Archive it first/)
|
|
1746
|
+
},
|
|
1747
|
+
)
|
|
1748
|
+
|
|
1749
|
+
it('throws if the release does not exist', () => {
|
|
1750
|
+
expect(() =>
|
|
1751
|
+
processActions({
|
|
1752
|
+
actions: [{type: 'release.delete', releaseId: releaseName}],
|
|
1753
|
+
transactionId,
|
|
1754
|
+
base: {},
|
|
1755
|
+
working: {},
|
|
1756
|
+
timestamp,
|
|
1757
|
+
grants: defaultGrants,
|
|
1758
|
+
}),
|
|
1759
|
+
).toThrow(/does not exist/)
|
|
1760
|
+
})
|
|
1761
|
+
|
|
1762
|
+
it('throws PermissionActionError when the update grant is denied', () => {
|
|
1763
|
+
const existing = createReleaseDoc({state: 'archived'} as Partial<SanityDocument>)
|
|
1764
|
+
expect(() =>
|
|
1765
|
+
processActions({
|
|
1766
|
+
actions: [{type: 'release.delete', releaseId: releaseName}],
|
|
1767
|
+
transactionId,
|
|
1768
|
+
base: {[releaseDocId]: existing},
|
|
1769
|
+
working: {[releaseDocId]: existing},
|
|
1770
|
+
timestamp,
|
|
1771
|
+
grants: {...defaultGrants, update: alwaysDeny},
|
|
1772
|
+
}),
|
|
1773
|
+
).toThrow(/permission to delete release/)
|
|
1774
|
+
})
|
|
1775
|
+
})
|
|
1776
|
+
|
|
1777
|
+
describe('mixed with liveEdit document actions', () => {
|
|
1778
|
+
const liveEditDocId = 'live-edit-doc'
|
|
1779
|
+
const liveEditDoc = {
|
|
1780
|
+
_id: liveEditDocId,
|
|
1781
|
+
_type: 'liveEditType',
|
|
1782
|
+
_createdAt: '2025-01-01T00:00:00.000Z',
|
|
1783
|
+
_updatedAt: '2025-01-01T00:00:00.000Z',
|
|
1784
|
+
_rev: 'initial',
|
|
1785
|
+
}
|
|
1786
|
+
const liveEditAction = {
|
|
1787
|
+
type: 'document.edit',
|
|
1788
|
+
documentId: liveEditDocId,
|
|
1789
|
+
documentType: 'liveEditType',
|
|
1790
|
+
liveEdit: true,
|
|
1791
|
+
patches: [{set: {title: 'x'}}],
|
|
1792
|
+
} as const
|
|
1793
|
+
|
|
1794
|
+
it('throws ActionError when a transaction mixes a release action with a liveEdit document action', () => {
|
|
1795
|
+
expect(() =>
|
|
1796
|
+
processActions({
|
|
1797
|
+
actions: [
|
|
1798
|
+
{
|
|
1799
|
+
type: 'release.create',
|
|
1800
|
+
releaseId: releaseName,
|
|
1801
|
+
metadata: {releaseType: 'undecided'},
|
|
1802
|
+
},
|
|
1803
|
+
liveEditAction as unknown as Action,
|
|
1804
|
+
],
|
|
1805
|
+
transactionId,
|
|
1806
|
+
base: {[liveEditDocId]: liveEditDoc},
|
|
1807
|
+
working: {[liveEditDocId]: liveEditDoc},
|
|
1808
|
+
timestamp,
|
|
1809
|
+
grants: defaultGrants,
|
|
1810
|
+
}),
|
|
1811
|
+
).toThrow(/Cannot combine liveEdit document actions with other actions/)
|
|
1812
|
+
})
|
|
1813
|
+
|
|
1814
|
+
it('throws ActionError when a transaction mixes a non-liveEdit document action with a liveEdit document action', () => {
|
|
1815
|
+
const otherDocId = 'other-doc'
|
|
1816
|
+
const otherDoc = {
|
|
1817
|
+
_id: otherDocId,
|
|
1818
|
+
_type: 'someType',
|
|
1819
|
+
_createdAt: '2025-01-01T00:00:00.000Z',
|
|
1820
|
+
_updatedAt: '2025-01-01T00:00:00.000Z',
|
|
1821
|
+
_rev: 'initial',
|
|
1822
|
+
} as SanityDocument
|
|
1823
|
+
|
|
1824
|
+
expect(() =>
|
|
1825
|
+
processActions({
|
|
1826
|
+
actions: [
|
|
1827
|
+
{
|
|
1828
|
+
type: 'document.edit',
|
|
1829
|
+
documentId: otherDocId,
|
|
1830
|
+
documentType: 'someType',
|
|
1831
|
+
patches: [{set: {title: 'y'}}],
|
|
1832
|
+
},
|
|
1833
|
+
liveEditAction as unknown as Action,
|
|
1834
|
+
],
|
|
1835
|
+
transactionId,
|
|
1836
|
+
base: {[liveEditDocId]: liveEditDoc, [otherDocId]: otherDoc},
|
|
1837
|
+
working: {[liveEditDocId]: liveEditDoc, [otherDocId]: otherDoc},
|
|
1838
|
+
timestamp,
|
|
1839
|
+
grants: defaultGrants,
|
|
1840
|
+
}),
|
|
1841
|
+
).toThrow(/Cannot combine liveEdit document actions with other actions/)
|
|
1842
|
+
})
|
|
1843
|
+
})
|
|
1844
|
+
})
|
|
1423
1845
|
})
|