UNPKG

@sanity/mutator

Version:

A set of models to make it easier to utilize the powerful real time collaborative features of Sanity

231 lines (193 loc) • 6.26 kB
import {Patcher} from '../patch' import {debug} from './debug' import {luid} from './luid' import {type Doc, type Mut} from './types' /** * Parameters attached to the mutation * * @internal */ export interface MutationParams { transactionId?: string transition?: string identity?: string previousRev?: string resultRev?: string mutations: Mut[] timestamp?: string effects?: {apply: unknown; revert: unknown} } /** * A mutation describing a number of operations on a single document. * This should be considered an immutable structure. Mutations are compiled * on first application, and any changes in properties will not effectively * change its behavior after that. * * @internal */ export class Mutation { params: MutationParams compiled?: (doc: Doc | null) => Doc | null _appliesToMissingDocument: boolean | undefined constructor(options: MutationParams) { this.params = options } get transactionId(): string | undefined { return this.params.transactionId } get transition(): string | undefined { return this.params.transition } get identity(): string | undefined { return this.params.identity } get previousRev(): string | undefined { return this.params.previousRev } get resultRev(): string | undefined { return this.params.resultRev } get mutations(): Mut[] { return this.params.mutations } get timestamp(): Date | undefined { if (typeof this.params.timestamp === 'string') { return new Date(this.params.timestamp) } return undefined } get effects(): | { apply: unknown revert: unknown } | undefined { return this.params.effects } assignRandomTransactionId(): void { this.params.transactionId = luid() this.params.resultRev = this.params.transactionId } appliesToMissingDocument(): boolean { if (typeof this._appliesToMissingDocument !== 'undefined') { return this._appliesToMissingDocument } // Only mutations starting with a create operation apply to documents that do not exist ... const firstMut = this.mutations[0] if (firstMut) { this._appliesToMissingDocument = Boolean( firstMut.create || firstMut.createIfNotExists || firstMut.createOrReplace, ) } else { this._appliesToMissingDocument = true } return this._appliesToMissingDocument } // Compiles all mutations into a handy function compile(): void { const operations: ((doc: Doc | null) => Doc | null)[] = [] // creation requires a _createdAt const getGuaranteedCreatedAt = (doc: Doc): string => doc?._createdAt || this.params.timestamp || new Date().toISOString() this.mutations.forEach((mutation) => { if (mutation.create) { // TODO: Fail entire patch if document did exist const create = (mutation.create || {}) as Doc operations.push((doc): Doc => { if (doc) { return doc } return Object.assign(create, { _createdAt: getGuaranteedCreatedAt(create), }) }) return } if (mutation.createIfNotExists) { const createIfNotExists = mutation.createIfNotExists || {} operations.push((doc) => doc === null ? Object.assign(createIfNotExists, { _createdAt: getGuaranteedCreatedAt(createIfNotExists), }) : doc, ) return } if (mutation.createOrReplace) { const createOrReplace = mutation.createOrReplace || {} operations.push(() => Object.assign(createOrReplace, { _createdAt: getGuaranteedCreatedAt(createOrReplace), }), ) return } if (mutation.delete) { operations.push(() => null) return } if (mutation.patch) { if ('query' in mutation.patch) { // @todo Warn/throw? Investigate if this can ever happen return } const patch = new Patcher(mutation.patch) operations.push((doc) => patch.apply(doc) as Doc | null) return } throw new Error(`Unsupported mutation ${JSON.stringify(mutation, null, 2)}`) }) // Assign `_updatedAt` to the timestamp of the mutation if set if (typeof this.params.timestamp === 'string') { operations.push((doc) => { return doc ? Object.assign(doc, {_updatedAt: this.params.timestamp}) : null }) } const prevRev = this.previousRev const rev = this.resultRev || this.transactionId this.compiled = (doc: Doc | null) => { if (prevRev && doc && prevRev !== doc._rev) { throw new Error( `Previous revision for this mutation was ${prevRev}, but the document revision is ${doc._rev}`, ) } let result: Doc | null = doc for (const operation of operations) { result = operation(result) } // Should update _rev? if (result && rev) { // Ensure that result is a unique object, even if the operation was a no-op if (result === doc) { result = Object.assign({}, doc) } result._rev = rev } return result } } apply(document: Doc | null): Doc | null { debug('Applying mutation %O to document %O', this.mutations, document) if (!this.compiled) { this.compile() } const result = this.compiled!(document) debug(' => %O', result) return result } static applyAll(document: Doc | null, mutations: Mutation[]): Doc | null { return mutations.reduce((doc, mutation) => mutation.apply(doc), document) } // Given a number of yet-to-be-committed mutation objects, collects them into one big mutation // any metadata like transactionId is ignored and must be submitted by the client. It is assumed // that all mutations are on the same document. // TOOO: Optimize mutations, eliminating mutations that overwrite themselves! static squash(document: Doc | null, mutations: Mutation[]): Mutation { const squashed = mutations.reduce( (result, mutation) => result.concat(...mutation.mutations), [] as Mut[], ) return new Mutation({mutations: squashed}) } }