@sanity/mutator
Version:
A set of models to make it easier to utilize the powerful real time collaborative features of Sanity
280 lines (243 loc) • 9.32 kB
text/typescript
import {makePatches, stringifyPatches} from '@sanity/diff-match-patch'
import {arrayToJSONMatchPath} from '../jsonpath/arrayToJSONMatchPath'
import {extractWithPath} from '../jsonpath/extractWithPath'
import {debug} from './debug'
import {Mutation} from './Mutation'
import {type Doc, type Mut} from './types'
/**
* Implements a buffer for mutations that incrementally optimises the mutations by
* eliminating set-operations that overwrite earlier set-operations, and rewrite
* set-operations that change strings into other strings into diffMatchPatch operations.
*
* @internal
*/
export class SquashingBuffer {
/**
* The document forming the basis of this squash
*/
BASIS: Doc | null
/**
* The document after the out-Mutation has been applied, but before the staged
* operations are committed.
*/
PRESTAGE: Doc | null
/**
* setOperations contain the latest set operation by path. If the set-operations are
* updating strings to new strings, they are rewritten as diffMatchPatch operations,
* any new set operations on the same paths overwrites any older set operations.
* Only set-operations assigning plain values to plain values gets optimized like this.
*/
setOperations: Record<string, Mut | undefined>
/**
* `documentPresent` is true whenever we know that the document must be present due
* to preceeding mutations. `false` implies that it may or may not already exist.
*/
documentPresent: boolean
/**
* The operations in the out-Mutation are not able to be optimized any further
*/
out: Mut[] = []
/**
* Staged mutation operations
*/
staged: Mut[]
constructor(doc: Doc | null) {
if (doc) {
debug('Reset mutation buffer to rev %s', doc._rev)
} else {
debug('Reset mutation buffer state to document being deleted')
}
this.staged = []
this.setOperations = {}
this.documentPresent = false
this.BASIS = doc
this.PRESTAGE = doc
}
add(mut: Mutation): void {
mut.mutations.forEach((op) => this.addOperation(op))
}
hasChanges(): boolean {
return this.out.length > 0 || Object.keys(this.setOperations).length > 0
}
/**
* Extracts the mutations in this buffer.
* After this is done, the buffer lifecycle is over and the client should
* create an new one with the new, updated BASIS.
*
* @param txnId - Transaction ID
* @returns A `Mutation` instance if we had outgoing mutations pending, null otherwise
*/
purge(txnId?: string): Mutation | null {
this.stashStagedOperations()
let result = null
if (this.out.length > 0) {
debug('Purged mutation buffer')
result = new Mutation({
mutations: this.out,
resultRev: txnId,
transactionId: txnId,
})
}
this.out = []
this.documentPresent = false
return result
}
addOperation(op: Mut): void {
// Is this a set patch, and only a set patch, and does it apply to the document at hand?
if (
op.patch &&
op.patch.set &&
'id' in op.patch &&
op.patch.id === this.PRESTAGE?._id &&
Object.keys(op.patch).length === 2 // `id` + `set`
) {
const setPatch = op.patch.set
const unoptimizable: Record<string, unknown> = {}
// Apply all optimisable keys in the patch
for (const path of Object.keys(setPatch)) {
if (setPatch.hasOwnProperty(path)) {
if (!this.optimiseSetOperation(path, setPatch[path])) {
// If not optimisable, add to unoptimizable set
unoptimizable[path] = setPatch[path]
}
}
}
// If any weren't optimisable, add them to an unoptimised set-operation, then
// stash everything.
if (Object.keys(unoptimizable).length > 0) {
debug('Unoptimizable set-operation detected, purging optimization buffer')
this.staged.push({patch: {id: this.PRESTAGE._id, set: unoptimizable}})
this.stashStagedOperations()
}
return
}
// Is this a createIfNotExists for our document?
if (op.createIfNotExists && this.PRESTAGE && op.createIfNotExists._id === this.PRESTAGE._id) {
if (!this.documentPresent) {
// If we don't know that it's present we'll have to stage and stash.
this.staged.push(op)
this.documentPresent = true
this.stashStagedOperations()
}
// Otherwise we can fully ignore it.
return
}
debug('Unoptimizable mutation detected, purging optimization buffer')
// console.log("Unoptimizable operation, stashing", JSON.stringify(op))
// Un-optimisable operations causes everything to be stashed
this.staged.push(op)
this.stashStagedOperations()
}
/**
* Attempt to perform one single set operation in an optimised manner, return value
* reflects whether or not the operation could be performed.
* @param path - The JSONPath to the set operation in question
* @param nextValue - The value to be set
* @returns True of optimized, false otherwise
*/
optimiseSetOperation(path: string, nextValue: unknown): boolean {
// console.log('optimiseSetOperation', path, nextValue)
// If target value is not a plain value, unable to optimise
if (typeof nextValue === 'object') {
// console.log("Not optimisable because next value is object")
return false
}
// Check the source values, if there is more than one value being assigned,
// we won't optimise
const matches = extractWithPath(path, this.PRESTAGE)
// If we are not overwriting exactly one key, this cannot be optimised, so we bail
if (matches.length !== 1) {
// console.log('Not optimisable because match count is != 1', JSON.stringify(matches))
return false
}
// Okay, we are assigning exactly one value to exactly one existing slot, so we might optimise
const match = matches[0]
// If the value of the match is an array or object, we cannot safely optimise this since the meaning
// of pre-existing operations might change (in theory, at least), so we bail
if (typeof match.value === 'object') {
// console.log("Not optimisable because old value is object")
return false
}
if (!this.PRESTAGE) {
// Shouldn't happen, but makes typescript happy
return false
}
// If the new and old value are the equal, we optimise this operation by discarding it
// Now, let's build the operation
let op: Mut | null = null
if (match.value === nextValue) {
// If new and old values are equal, we optimise this by deleting the operation
// console.log("Omitting operation")
op = null
} else if (typeof match.value === 'string' && typeof nextValue === 'string') {
// console.log("Rewriting to dmp")
// We are updating a string to another string, so we are making a diffMatchPatch
try {
const patch = stringifyPatches(makePatches(match.value, nextValue))
op = {patch: {id: this.PRESTAGE._id, diffMatchPatch: {[path]: patch}}}
} catch {
// patch_make failed due to unicode issue https://github.com/google/diff-match-patch/issues/59
return false
}
} else {
// console.log("Not able to rewrite to dmp, making normal set")
// We are changing the type of the value, so must make a normal set-operation
op = {patch: {id: this.PRESTAGE._id, set: {[path]: nextValue}}}
}
// Let's make a plain, concrete path from the array-path. We use this to keep only the latest set
// operation touching this path in the buffer.
const canonicalPath = arrayToJSONMatchPath(match.path)
// Store this operation, overwriting any previous operations touching this same path
if (op) {
this.setOperations[canonicalPath] = op
} else {
delete this.setOperations[canonicalPath]
}
// Signal that we succeeded in optimizing this patch
return true
}
stashStagedOperations(): void {
// Short circuit if there are no staged operations
const nextOps: Mut[] = []
// Extract the existing outgoing operations if any
Object.keys(this.setOperations).forEach((key) => {
const op = this.setOperations[key]
if (op) {
nextOps.push(op)
}
})
nextOps.push(...this.staged)
if (nextOps.length > 0) {
this.PRESTAGE = new Mutation({mutations: nextOps}).apply(this.PRESTAGE) as Doc
this.staged = []
this.setOperations = {}
}
this.out.push(...nextOps)
}
/**
* Rebases given the new base-document
*
* @param newBasis - New base document to rebase on
* @returns New "edge" document with buffered changes integrated
*/
rebase(newBasis: Doc | null): Doc | null {
this.stashStagedOperations()
if (newBasis === null) {
// If document was just deleted, we must throw out local changes
this.out = []
this.BASIS = newBasis
this.PRESTAGE = newBasis
this.documentPresent = false
} else {
this.BASIS = newBasis
// @todo was this supposed to be `this.out.length > 0`?
// surely this is always `true`?
if (this.out) {
this.PRESTAGE = new Mutation({mutations: this.out}).apply(this.BASIS) as Doc
} else {
this.PRESTAGE = this.BASIS
}
}
return this.PRESTAGE
}
}