@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
text/typescript
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})
}
}