UNPKG

@sanity/sdk

Version:
270 lines (235 loc) 8.08 kB
import { type Mutation, type MutationSelection, type PatchOperations, type SanityDocument, } from '@sanity/types' import { dec, diffMatchPatch, ifRevisionID, inc, insert, set, setIfMissing, unset, } from './patchOperations' /** * Represents a set of document that will go into `applyMutations`. Before * applying a mutation, it's expected that all relevant documents that the * mutations affect are included, including those that do not exist yet. * Documents that don't exist have a `null` value. */ export type DocumentSet<TDocument extends SanityDocument = SanityDocument> = { [TDocumentId in string]?: TDocument | null } type SupportedPatchOperation = Exclude<keyof PatchOperations, 'merge'> // > If multiple patches are included, then the order of execution is as follows: // > - set, setIfMissing, unset, inc, dec, insert. // > https://www.sanity.io/docs/http-mutations#5b4db1396e56 const patchOperations = { ifRevisionID, set, setIfMissing, unset, inc, dec, insert, diffMatchPatch, } satisfies { [K in SupportedPatchOperation]: ( input: unknown, pathExpressions: NonNullable<PatchOperations[K]>, ) => unknown } /** * Implements ID generation: * * A create mutation creates a new document. It takes the literal document * content as its argument. The rules for the new document's identifier are as * follows: * * - If the `_id` attribute is missing, then a new, random, unique ID is * generated. * - If the `_id` attribute is present but ends with `.`, then it is used as a * prefix for a new, random, unique ID. * - If the _id attribute is present, it is used as-is. * * [- source](https://www.sanity.io/docs/http-mutations#c732f27330a4) */ export function getId(id?: string): string { if (!id || typeof id !== 'string') return crypto.randomUUID() if (id.endsWith('.')) return `${id}${crypto.randomUUID()}` return id } interface ProcessMutationsOptions { /** * The transaction ID that will become the next `_rev` for documents mutated * by the given mutations. */ transactionId: string /** * The input document set that the mutations will be applied to. */ documents: DocumentSet /** * A list of mutations to apply to the given document set. */ mutations: Mutation[] /** * An optional timestamp that will be used for `_createdAt` and `_updatedAt` * timestamp when applicable. */ timestamp?: string } export function getDocumentIds(selection: MutationSelection): string[] { if ('id' in selection) { // NOTE: the `MutationSelection` type within `@sanity/client` (instead of // `@sanity/types`) allows for the ID field to be an array of strings so we // support that as well const array = Array.isArray(selection.id) ? selection.id : [selection.id] const ids = array.filter((id): id is string => typeof id === 'string') return Array.from(new Set(ids)) } if ('query' in selection) { throw new Error(`'query' in mutations is not supported.`) } return [] } /** * Applies the given mutation to the given document set. Note, it is expected * that all relevant documents that the mutations affect should be within the * given `document` set. If a document does not exist, it should have the value * `null`. If a document is deleted as a result of the mutations, it will still * have its document ID present in the returns documents, but it will have a * value of `null`. * * The given `transactionId` will be used as the resulting `_rev` for documents * affected by the given set of mutations. * * If a `timestamp` is given, that will be used as for the relevant `_updatedAt` * and `_createdAt` timestamps. */ export function processMutations({ documents, mutations, transactionId, timestamp, }: ProcessMutationsOptions): DocumentSet { // early return if there are no mutations given if (!mutations.length) return documents const dataset = {...documents} const now = timestamp || new Date().toISOString() for (const mutation of mutations) { if ('create' in mutation) { const id = getId(mutation.create._id) if (dataset[id]) { throw new Error( `Cannot create document with \`_id\` \`${id}\` because another document with the same ID already exists.`, ) } const document: SanityDocument = { // > `_createdAt` and `_updatedAt` may be submitted and will override // > the default which is of course the current time. This can be used // > to reconstruct a data-set with its timestamp structure intact. // > // > [- source](https://www.sanity.io/docs/http-mutations#c732f27330a4) _createdAt: now, _updatedAt: now, ...mutation.create, // prefer the user's `_createdAt` and `_updatedAt` _rev: transactionId, _id: id, } dataset[id] = document continue } if ('createOrReplace' in mutation) { const id = getId(mutation.createOrReplace._id) const prev = dataset[id] const document: SanityDocument = { ...mutation.createOrReplace, // otherwise, if the mutation provided, a `_createdAt` time, use it, // otherwise default to now _createdAt: // if there was an existing document, use the previous `_createdAt` // since we're replacing the current document prev?._createdAt || // if there was no previous document, then we're creating this // document for the first time so we should use the `_createdAt` from // the mutation if the user included it (typeof mutation.createOrReplace['_createdAt'] === 'string' && mutation.createOrReplace['_createdAt']) || // otherwise, default to now now, _updatedAt: // if there was an existing document, then set the `_updatedAt` to now // since we're replacing the current document prev ? now : // otherwise, we're creating this document for the first time, // in that case, use the user's `_updatedAt` if included in the // mutation (typeof mutation.createOrReplace['_updatedAt'] === 'string' && mutation.createOrReplace['_updatedAt']) || // otherwise default to now now, _rev: transactionId, _id: id, } dataset[id] = document continue } if ('createIfNotExists' in mutation) { const id = getId(mutation.createIfNotExists._id) const prev = dataset[id] if (prev) continue const document: SanityDocument = { // same logic as `create`: // prefer the user's `_createdAt` and `_updatedAt` _createdAt: now, _updatedAt: now, ...mutation.createIfNotExists, _rev: transactionId, _id: id, } dataset[id] = document continue } if ('delete' in mutation) { for (const id of getDocumentIds(mutation.delete)) { dataset[id] = null } continue } if ('patch' in mutation) { const {patch} = mutation const ids = getDocumentIds(patch) const patched = ids.map((id) => { if (!dataset[id]) { throw new Error(`Cannot patch document with ID \`${id}\` because it was not found.`) } type Entries<T> = {[K in keyof T]: [K, T[K]]}[keyof T][] const entries = Object.entries(patchOperations) as Entries<typeof patchOperations> return entries.reduce((acc, [type, operation]) => { if (patch[type]) { return operation( acc, // @ts-expect-error TS doesn't handle this union very well patch[type], ) } return acc }, dataset[id]) }) for (const result of patched) { dataset[result._id] = { ...result, _rev: transactionId, _updatedAt: now, } } continue } } return dataset }