UNPKG

@sanity/sdk

Version:
708 lines (606 loc) 23.2 kB
import {diffValue} from '@sanity/diff-patch' import { type Mutation, type PatchOperations, type Reference, type SanityDocument, } from '@sanity/types' import {evaluateSync, type ExprNode} from 'groq-js' import {isEqual} from 'lodash-es' import {getDraftId, getPublishedId} from '../utils/ids' import {type DocumentAction} from './actions' import {type Grant} from './permissions' import {type DocumentSet, getId, processMutations} from './processMutations' import {type HttpAction} from './reducers' function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean { const value = evaluateSync(grantExpr, {params: {document}}) return value.type === 'boolean' && value.data } interface ProcessActionsOptions { /** * The ID of this transaction. This will become the resulting `_rev` for all * documents affected by changes derived from the current set of actions. */ transactionId: string /** * The actions to apply to the given documents */ actions: DocumentAction[] /** * The set of documents these actions were intended to be applied to. These * set of documents should be captured right before a queued action is * applied. */ base: DocumentSet /** * The current "working" set of documents. A patch will be created by applying * the actions to the base. This patch will then be applied to the working * set for conflict resolution. Initially, this value should match the base * set. */ working: DocumentSet /** * The timestamp to use for `_updateAt` and other similar timestamps for this * transaction */ timestamp: string /** * the lookup with pre-parsed GROQ expressions */ grants: Record<Grant, ExprNode> // // TODO: implement initial values from the schema? // initialValues?: {[TDocumentType in string]?: {_type: string}} } interface ProcessActionsResult { /** * The resulting document set after the actions have been applied. This is * derived from the working documents. */ working: DocumentSet /** * The document set before the actions have been applied. This is simply the * input of the `working` document set. */ previous: DocumentSet /** * The outgoing action that were collected when applying the actions. These * are sent to the Actions HTTP API */ outgoingActions: HttpAction[] /** * The outgoing mutations that were collected when applying the actions. These * are here for debugging purposes. */ outgoingMutations: Mutation[] /** * The previous revisions of the given documents before the actions were applied. */ previousRevs: {[TDocumentId in string]?: string} } interface ActionErrorOptions { message: string documentId: string transactionId: string } /** * Thrown when a precondition for an action failed. */ export class ActionError extends Error implements ActionErrorOptions { documentId!: string transactionId!: string constructor(options: ActionErrorOptions) { super(options.message) Object.assign(this, options) } } export class PermissionActionError extends ActionError {} /** * Applies the given set of actions to the working set of documents and converts * high-level actions into lower-level outgoing mutations/actions that respect * the current state of the working documents. * * Supports a "base" and "working" set of documents to allow actions to be * applied on top of a different working set of documents in a 3-way merge * * Actions are applied to the base set of documents first. The difference * between the base before and after is used to create a patch. This patch is * then applied to the working set of documents and is set as the outgoing patch * sent to the server. */ export function processActions({ actions, transactionId, working: initialWorking, base: initialBase, timestamp, grants, }: ProcessActionsOptions): ProcessActionsResult { let working: DocumentSet = {...initialWorking} let base: DocumentSet = {...initialBase} const outgoingActions: HttpAction[] = [] const outgoingMutations: Mutation[] = [] for (const action of actions) { switch (action.type) { case 'document.create': { const documentId = getId(action.documentId) if (action.liveEdit) { // For liveEdit documents, create directly without draft/published logic if (working[documentId]) { throw new ActionError({ documentId, transactionId, message: `This document already exists.`, }) } const newDocBase = {_type: action.documentType, _id: documentId} const newDocWorking = {_type: action.documentType, _id: documentId} const mutations: Mutation[] = [{create: newDocWorking}] base = processMutations({ documents: base, transactionId, mutations: [{create: newDocBase}], timestamp, }) working = processMutations({ documents: working, transactionId, mutations, timestamp, }) if (!checkGrant(grants.create, working[documentId] as SanityDocument)) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to create document "${documentId}".`, }) } outgoingMutations.push(...mutations) outgoingActions.push({ actionType: 'sanity.action.document.create', publishedId: documentId, attributes: newDocWorking, }) continue } // Standard draft/published logic const draftId = getDraftId(documentId) const publishedId = getPublishedId(documentId) if (working[draftId]) { throw new ActionError({ documentId, transactionId, message: `A draft version of this document already exists. Please use or discard the existing draft before creating a new one.`, }) } // Spread the (possibly undefined) published version directly. const newDocBase = { ...base[publishedId], _type: action.documentType, _id: draftId, ...action.initialValue, } const newDocWorking = { ...working[publishedId], _type: action.documentType, _id: draftId, ...action.initialValue, } const mutations: Mutation[] = [{create: newDocWorking}] base = processMutations({ documents: base, transactionId, mutations: [{create: newDocBase}], timestamp, }) working = processMutations({ documents: working, transactionId, mutations, timestamp, }) if (!checkGrant(grants.create, working[draftId] as SanityDocument)) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to create a draft for document "${documentId}".`, }) } outgoingMutations.push(...mutations) outgoingActions.push({ actionType: 'sanity.action.document.version.create', publishedId, attributes: newDocWorking, }) continue } case 'document.delete': { const documentId = action.documentId if (action.liveEdit) { // For liveEdit documents, delete directly if (!working[documentId]) { throw new ActionError({ documentId, transactionId, message: 'The document you are trying to delete does not exist.', }) } if (!checkGrant(grants.update, working[documentId])) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to delete this document.`, }) } const mutations: Mutation[] = [{delete: {id: documentId}}] base = processMutations({documents: base, transactionId, mutations, timestamp}) working = processMutations({documents: working, transactionId, mutations, timestamp}) outgoingMutations.push(...mutations) outgoingActions.push({ actionType: 'sanity.action.document.delete', publishedId: documentId, }) continue } // Standard draft/published logic const draftId = getDraftId(documentId) const publishedId = getPublishedId(documentId) if (!working[publishedId]) { throw new ActionError({ documentId, transactionId, message: working[draftId] ? 'Cannot delete a document without a published version.' : 'The document you are trying to delete does not exist.', }) } const cantDeleteDraft = working[draftId] && !checkGrant(grants.update, working[draftId]) const cantDeletePublished = working[publishedId] && !checkGrant(grants.update, working[publishedId]) if (cantDeleteDraft || cantDeletePublished) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to delete this document.`, }) } const mutations: Mutation[] = [{delete: {id: publishedId}}, {delete: {id: draftId}}] const includeDrafts = working[draftId] ? [draftId] : undefined base = processMutations({documents: base, transactionId, mutations, timestamp}) working = processMutations({documents: working, transactionId, mutations, timestamp}) outgoingMutations.push(...mutations) outgoingActions.push({ actionType: 'sanity.action.document.delete', publishedId, ...(includeDrafts ? {includeDrafts} : {}), }) continue } case 'document.discard': { const documentId = getId(action.documentId) if (action.liveEdit) { throw new ActionError({ documentId, transactionId, message: `Cannot discard changes for liveEdit document "${documentId}". LiveEdit documents do not support drafts.`, }) } // Standard draft/published logic const draftId = getDraftId(documentId) const mutations: Mutation[] = [{delete: {id: draftId}}] if (!working[draftId]) { throw new ActionError({ documentId, transactionId, message: `There is no draft available to discard for document "${documentId}".`, }) } if (!checkGrant(grants.update, working[draftId])) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to discard changes for document "${documentId}".`, }) } base = processMutations({documents: base, transactionId, mutations, timestamp}) working = processMutations({documents: working, transactionId, mutations, timestamp}) outgoingMutations.push(...mutations) outgoingActions.push({ actionType: 'sanity.action.document.version.discard', versionId: draftId, }) continue } case 'document.edit': { const documentId = getId(action.documentId) if (action.liveEdit) { // For liveEdit documents, edit directly without draft logic const userPatches = action.patches?.map((patch) => ({patch: {id: documentId, ...patch}})) // skip this action if there are no associated patches if (!userPatches?.length) continue if (!working[documentId] || !base[documentId]) { throw new ActionError({ documentId, transactionId, message: `Cannot edit document because it does not exist.`, }) } const baseBefore = base[documentId] as SanityDocument if (userPatches) { base = processMutations({ documents: base, transactionId, mutations: userPatches, timestamp, }) } const baseAfter = base[documentId] as SanityDocument const patches = diffValue(baseBefore, baseAfter) const workingBefore = working[documentId] as SanityDocument if (!checkGrant(grants.update, workingBefore)) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to edit document "${documentId}".`, }) } const workingMutations = patches.map((patch) => ({patch: {id: documentId, ...patch}})) working = processMutations({ documents: working, transactionId, mutations: workingMutations, timestamp, }) outgoingMutations.push(...workingMutations) outgoingActions.push( ...patches.map( (patch): HttpAction => ({ actionType: 'sanity.action.document.edit', // Server requires draftId to have drafts. prefix for validation, even for liveEdit draftId: getDraftId(documentId), publishedId: documentId, patch: patch as PatchOperations, }), ), ) continue } // Standard draft/published logic const draftId = getDraftId(documentId) const publishedId = getPublishedId(documentId) const userPatches = action.patches?.map((patch) => ({patch: {id: draftId, ...patch}})) // skip this action if there are no associated patches if (!userPatches?.length) continue if ( (!working[draftId] && !working[publishedId]) || (!base[draftId] && !base[publishedId]) ) { throw new ActionError({ documentId, transactionId, message: `Cannot edit document because it does not exist in draft or published form.`, }) } const baseMutations: Mutation[] = [] if (!base[draftId] && base[publishedId]) { baseMutations.push({create: {...base[publishedId], _id: draftId}}) } // the first if statement should make this never be null or undefined const baseBefore = (base[draftId] ?? base[publishedId]) as SanityDocument if (userPatches) { baseMutations.push(...userPatches) } base = processMutations({ documents: base, transactionId, mutations: baseMutations, timestamp, }) // this one will always be defined because a patch mutation will never // delete an input document const baseAfter = base[draftId] as SanityDocument const patches = diffValue(baseBefore, baseAfter) const workingMutations: Mutation[] = [] if (!working[draftId] && working[publishedId]) { const newDraftFromPublished = {...working[publishedId], _id: draftId} if (!checkGrant(grants.create, newDraftFromPublished)) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to create a draft for editing this document.`, }) } workingMutations.push({create: newDraftFromPublished}) } // the first if statement should make this never be null or undefined const workingBefore = (working[draftId] ?? working[publishedId]) as SanityDocument if (!checkGrant(grants.update, workingBefore)) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to edit document "${documentId}".`, }) } workingMutations.push(...patches.map((patch) => ({patch: {id: draftId, ...patch}}))) working = processMutations({ documents: working, transactionId, mutations: workingMutations, timestamp, }) outgoingMutations.push(...workingMutations) outgoingActions.push( ...patches.map( (patch): HttpAction => ({ actionType: 'sanity.action.document.edit', draftId, publishedId, patch: patch as PatchOperations, }), ), ) continue } case 'document.publish': { const documentId = getId(action.documentId) if (action.liveEdit) { throw new ActionError({ documentId, transactionId, message: `Cannot publish liveEdit document "${documentId}". LiveEdit documents do not support drafts or publishing.`, }) } // Standard draft/published logic const draftId = getDraftId(documentId) const publishedId = getPublishedId(documentId) const workingDraft = working[draftId] const baseDraft = base[draftId] if (!workingDraft || !baseDraft) { throw new ActionError({ documentId, transactionId, message: `Cannot publish because no draft version was found for document "${documentId}".`, }) } // Before proceeding, verify that the working draft is identical to the base draft. // TODO: is it enough just to check for the _rev or nah? if (!isEqual(workingDraft, baseDraft)) { throw new ActionError({ documentId, transactionId, message: `Publish aborted: The document has changed elsewhere. Please try again.`, }) } const newPublishedFromDraft = {...strengthenOnPublish(workingDraft), _id: publishedId} const mutations: Mutation[] = [ {delete: {id: draftId}}, {createOrReplace: newPublishedFromDraft}, ] if (working[draftId] && !checkGrant(grants.update, working[draftId])) { throw new PermissionActionError({ documentId, transactionId, message: `Publish failed: You do not have permission to update the draft for "${documentId}".`, }) } if (working[publishedId] && !checkGrant(grants.update, newPublishedFromDraft)) { throw new PermissionActionError({ documentId, transactionId, message: `Publish failed: You do not have permission to update the published version of "${documentId}".`, }) } else if (!working[publishedId] && !checkGrant(grants.create, newPublishedFromDraft)) { throw new PermissionActionError({ documentId, transactionId, message: `Publish failed: You do not have permission to publish a new version of "${documentId}".`, }) } base = processMutations({documents: base, transactionId, mutations, timestamp}) working = processMutations({documents: working, transactionId, mutations, timestamp}) outgoingMutations.push(...mutations) outgoingActions.push({ actionType: 'sanity.action.document.publish', draftId, publishedId, }) continue } case 'document.unpublish': { const documentId = getId(action.documentId) if (action.liveEdit) { throw new ActionError({ documentId, transactionId, message: `Cannot unpublish liveEdit document "${documentId}". LiveEdit documents do not support drafts or publishing.`, }) } // Standard draft/published logic const draftId = getDraftId(documentId) const publishedId = getPublishedId(documentId) if (!working[publishedId] && !base[publishedId]) { throw new ActionError({ documentId, transactionId, message: `Cannot unpublish because the document "${documentId}" is not currently published.`, }) } const sourceDoc = working[publishedId] ?? (base[publishedId] as SanityDocument) const newDraftFromPublished = {...sourceDoc, _id: draftId} const mutations: Mutation[] = [ {delete: {id: publishedId}}, {createIfNotExists: newDraftFromPublished}, ] if (!checkGrant(grants.update, sourceDoc)) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to unpublish the document "${documentId}".`, }) } if (!working[draftId] && !checkGrant(grants.create, newDraftFromPublished)) { throw new PermissionActionError({ documentId, transactionId, message: `You do not have permission to create a draft from the published version of "${documentId}".`, }) } base = processMutations({ documents: base, transactionId, mutations: [ {delete: {id: publishedId}}, {createIfNotExists: {...(base[publishedId] ?? sourceDoc), _id: draftId}}, ], timestamp, }) working = processMutations({documents: working, transactionId, mutations, timestamp}) outgoingMutations.push(...mutations) outgoingActions.push({ actionType: 'sanity.action.document.unpublish', draftId, publishedId, }) continue } default: { throw new Error( `Unknown action type: "${ // @ts-expect-error invalid input action.type }". Please contact support if this issue persists.`, ) } } } const previousRevs = Object.fromEntries( Object.entries(initialWorking).map(([id, doc]) => [id, doc?._rev]), ) return { working, outgoingActions, outgoingMutations, previous: initialWorking, previousRevs, } } function strengthenOnPublish(draft: SanityDocument): SanityDocument { const isStrengthenReference = ( value: object, ): value is Reference & Required<Pick<Reference, '_strengthenOnPublish'>> => '_strengthenOnPublish' in value function strengthen(value: unknown): unknown { if (typeof value !== 'object' || !value) return value if (isStrengthenReference(value)) { const {_strengthenOnPublish, _weak, ...rest} = value return { ...rest, ...(_strengthenOnPublish.weak && {_weak: true}), } } if (Array.isArray(value)) { return value.map(strengthen) } return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, strengthen(v)])) } return strengthen(draft) as SanityDocument }