@sanity/mutator
Version:
A set of models to make it easier to utilize the powerful real time collaborative features of Sanity
424 lines (365 loc) • 14.4 kB
text/typescript
// TODO: When we have timestamps on mutation notifications, we can reject incoming mutations that are older
// than the document we are seeing.
import {isEqual} from 'lodash'
import {debug} from './debug'
import {Mutation} from './Mutation'
import {type Doc} from './types'
/**
* @internal
*/
export interface SubmissionResponder {
success: () => void
failure: () => void
}
/**
* Models a document as it is changed by our own local patches and remote patches coming in from
* the server. Consolidates incoming patches with our own submitted patches and maintains two
* versions of the document. EDGE is the optimistic document that the user sees that will always
* immediately reflect whatever she is doing to it, and HEAD which is the confirmed version of the
* document consistent with the mutations we have received from the server. As long as nothing out of
* the ordinary happens, we can track all changes by hooking into the onMutation callback, but we
* must also respect onRebase events that fire when we have to backtrack because one of our optimistically
* applied patches were rejected, or some bastard was able to slip a mutation in between ours own.
*
* @internal
*/
export class Document {
/**
* Incoming patches from the server waiting to be applied to HEAD
*/
incoming: Mutation[] = []
/**
* Patches we know has been subitted to the server, but has not been seen yet in the return channel
* so we can't be sure about the ordering yet (someone else might have slipped something between them)
*/
submitted: Mutation[] = []
/**
* Pending mutations
*/
pending: Mutation[] = []
/**
* Our model of the document according to the incoming patches from the server
*/
HEAD: Doc | null
/**
* Our optimistic model of what the document will probably look like as soon as all our patches
* have been processed. Updated every time we stage a new mutation, but also might revert back
* to previous states if our mutations fail, or could change if unexpected mutations arrive
* between our own. The `onRebase` callback will be called when EDGE changes in this manner.
*/
EDGE: Doc | null
/**
* Called with the EDGE document when that document changes for a reason other than us staging
* a new patch or receiving a mutation from the server while our EDGE is in sync with HEAD:
* I.e. when EDGE changes because the order of mutations has changed in relation to our
* optimistic predictions.
*/
onRebase?: (edge: Doc | null, incomingMutations: Mutation[], pendingMutations: Mutation[]) => void
/**
* Called when we receive a patch in the normal order of things, but the mutation is not ours
*/
onMutation?: (msg: {mutation: Mutation; document: Doc | null; remote: boolean}) => void
/**
* Called when consistency state changes with the boolean value of the current consistency state
*/
onConsistencyChanged?: (isConsistent: boolean) => void
/**
* Called whenever a new incoming mutation comes in. These are always ordered correctly.
*/
onRemoteMutation?: (mut: Mutation) => void
/**
* We are consistent when there are no unresolved mutations of our own, and no un-applicable
* incoming mutations. When this has been going on for too long, and there has been a while
* since we staged a new mutation, it is time to reset your state.
*/
inconsistentAt: Date | null = null
/**
* The last time we staged a patch of our own. If we have been inconsistent for a while, but it
* hasn't been long since we staged a new mutation, the reason is probably just because the user
* is typing or something.
*
* Should be used as a guard against resetting state for inconsistency reasons.
*/
lastStagedAt: Date | null = null
constructor(doc: Doc | null) {
this.reset(doc)
this.HEAD = doc
this.EDGE = doc
}
// Reset the state of the Document, used to recover from unsavory states by reloading the document
reset(doc: Doc | null): void {
this.incoming = []
this.submitted = []
this.pending = []
this.inconsistentAt = null
this.HEAD = doc
this.EDGE = doc
this.considerIncoming()
this.updateConsistencyFlag()
}
// Call when a mutation arrives from Sanity
arrive(mutation: Mutation): void {
this.incoming.push(mutation)
this.considerIncoming()
this.updateConsistencyFlag()
}
// Call to signal that we are submitting a mutation. Returns a callback object with a
// success and failure handler that must be called according to the outcome of our
// submission.
stage(mutation: Mutation, silent?: boolean): SubmissionResponder {
if (!mutation.transactionId) {
throw new Error('Mutations _must_ have transactionId when submitted')
}
this.lastStagedAt = new Date()
debug('Staging mutation %s (pushed to pending)', mutation.transactionId)
this.pending.push(mutation)
this.EDGE = mutation.apply(this.EDGE)
if (this.onMutation && !silent) {
this.onMutation({
mutation,
document: this.EDGE,
remote: false,
})
}
const txnId = mutation.transactionId
this.updateConsistencyFlag()
return {
success: () => {
this.pendingSuccessfullySubmitted(txnId)
this.updateConsistencyFlag()
},
failure: () => {
this.pendingFailed(txnId)
this.updateConsistencyFlag()
},
}
}
// Call to check if everything is nice and quiet and there are no unresolved mutations.
// Means this model thinks both HEAD and EDGE is up to date with what the server sees.
isConsistent(): boolean {
return !this.inconsistentAt
}
// Private
// Attempts to apply any resolvable incoming patches to HEAD. Will keep patching as long as there
// are applicable patches to be applied
considerIncoming(): void {
let mustRebase = false
let nextMut: Mutation | undefined
const rebaseMutations: Mutation[] = []
// Filter mutations that are older than the document
if (this.HEAD && this.HEAD._updatedAt) {
const updatedAt = new Date(this.HEAD._updatedAt)
if (this.incoming.find((mut) => mut.timestamp && mut.timestamp < updatedAt)) {
this.incoming = this.incoming.filter((mut) => mut.timestamp && mut.timestamp < updatedAt)
}
}
// Keep applying mutations as long as any apply
let protect = 0
do {
// Find next mutation that can be applied to HEAD (if any)
if (this.HEAD) {
const HEAD = this.HEAD
nextMut = HEAD._rev ? this.incoming.find((mut) => mut.previousRev === HEAD._rev) : undefined
} else {
// When HEAD is null, that means the document is currently deleted. Only mutations that start with a create
// operation will be considered.
nextMut = this.incoming.find((mut) => mut.appliesToMissingDocument())
}
if (nextMut) {
const applied = this.applyIncoming(nextMut)
mustRebase = mustRebase || applied
if (mustRebase) {
rebaseMutations.push(nextMut)
}
if (protect++ > 10) {
throw new Error(
`Mutator stuck flushing incoming mutations. Probably stuck here: ${JSON.stringify(
nextMut,
)}`,
)
}
}
} while (nextMut)
if (this.incoming.length > 0 && debug.enabled) {
debug(
'Unable to apply mutations %s',
this.incoming.map((mut) => mut.transactionId).join(', '),
)
}
if (mustRebase) {
this.rebase(rebaseMutations)
}
}
// check current consistency state, update flag and invoke callback if needed
updateConsistencyFlag(): void {
const wasConsistent = this.isConsistent()
const isConsistent =
this.pending.length === 0 && this.submitted.length === 0 && this.incoming.length === 0
// Update the consistency state, taking care not to update the timestamp if we were inconsistent and still are
if (isConsistent) {
this.inconsistentAt = null
} else if (!this.inconsistentAt) {
this.inconsistentAt = new Date()
}
// Handle onConsistencyChanged callback
if (wasConsistent != isConsistent && this.onConsistencyChanged) {
if (isConsistent) {
debug('Buffered document is inconsistent')
} else {
debug('Buffered document is consistent')
}
this.onConsistencyChanged(isConsistent)
}
}
// apply an incoming patch that has been prequalified as the next in line for this document
applyIncoming(mut: Mutation | undefined): boolean {
if (!mut) {
return false
}
if (!mut.transactionId) {
throw new Error('Received incoming mutation without a transaction ID')
}
debug(
'Applying mutation %s -> %s to rev %s',
mut.previousRev,
mut.resultRev,
this.HEAD && this.HEAD._rev,
)
this.HEAD = mut.apply(this.HEAD)
if (this.onRemoteMutation) {
this.onRemoteMutation(mut)
}
// Eliminate from incoming set
this.incoming = this.incoming.filter((m) => m.transactionId !== mut.transactionId)
if (this.hasUnresolvedMutations()) {
const needRebase = this.consumeUnresolved(mut.transactionId)
if (debug.enabled) {
debug(
`Incoming mutation ${mut.transactionId} appeared while there were pending or submitted local mutations`,
)
debug(`Submitted txnIds: ${this.submitted.map((m) => m.transactionId).join(', ')}`)
debug(`Pending txnIds: ${this.pending.map((m) => m.transactionId).join(', ')}`)
debug(`needRebase === %s`, needRebase)
}
return needRebase
}
debug(
`Remote mutation %s arrived w/o any pending or submitted local mutations`,
mut.transactionId,
)
this.EDGE = this.HEAD
if (this.onMutation) {
this.onMutation({
mutation: mut,
document: this.EDGE,
remote: true,
})
}
return false
}
/**
* Returns true if there are unresolved mutations between HEAD and EDGE, meaning we have
* mutations that are still waiting to be either submitted, or to be confirmed by the server.
*
* @returns true if there are unresolved mutations between HEAD and EDGE, false otherwise
*/
hasUnresolvedMutations(): boolean {
return this.submitted.length > 0 || this.pending.length > 0
}
/**
* When an incoming mutation is applied to HEAD, this is called to remove the mutation from
* the unresolved state. If the newly applied patch is the next upcoming unresolved mutation,
* no rebase is needed, but we might have the wrong idea about the ordering of mutations, so in
* that case we are given the flag `needRebase` to tell us that this mutation arrived out of
* order in terms of our optimistic version, so a rebase is needed.
*
* @param txnId - Transaction ID of the remote mutation
* @returns true if rebase is needed, false otherwise
*/
consumeUnresolved(txnId: string): boolean {
// If we have nothing queued up, we are in sync and can apply patch with no
// rebasing
if (this.submitted.length === 0 && this.pending.length === 0) {
return false
}
// If we can consume the directly upcoming mutation, we won't have to rebase
if (this.submitted.length !== 0) {
if (this.submitted[0].transactionId === txnId) {
debug(
`Remote mutation %s matches upcoming submitted mutation, consumed from 'submitted' buffer`,
txnId,
)
this.submitted.shift()
return false
}
} else if (this.pending.length > 0 && this.pending[0].transactionId === txnId) {
// There are no submitted, but some are pending so let's check the upcoming pending
debug(
`Remote mutation %s matches upcoming pending mutation, consumed from 'pending' buffer`,
txnId,
)
this.pending.shift()
return false
}
debug(
'The mutation was not the upcoming mutation, scrubbing. Pending: %d, Submitted: %d',
this.pending.length,
this.submitted.length,
)
// The mutation was not the upcoming mutation, so we'll have to check everything to
// see if we have an out of order situation
this.submitted = this.submitted.filter((mut) => mut.transactionId !== txnId)
this.pending = this.pending.filter((mut) => mut.transactionId !== txnId)
debug(`After scrubbing: Pending: %d, Submitted: %d`, this.pending.length, this.submitted.length)
// Whether we had it or not we have either a reordering, or an unexpected mutation
// so must rebase
return true
}
pendingSuccessfullySubmitted(pendingTxnId: string): void {
if (this.pending.length === 0) {
// If there are no pending, it has probably arrived allready
return
}
const first = this.pending[0]
if (first.transactionId === pendingTxnId) {
// Nice, the pending transaction arrived in order
this.pending.shift()
this.submitted.push(first)
return
}
// Oh, no. Submitted out of order.
let justSubmitted: Mutation | undefined
const stillPending: Mutation[] = []
this.pending.forEach((mutation) => {
if (mutation.transactionId === pendingTxnId) {
justSubmitted = mutation
return
}
stillPending.push(mutation)
})
// Not found? Hopefully it has already arrived. Might have been forgotten by now
if (justSubmitted) {
this.submitted.push(justSubmitted)
}
this.pending = stillPending
// Must rebase since mutation order has changed
this.rebase([])
}
pendingFailed(pendingTxnId: string): void {
this.pending = this.pending.filter((mutation) => mutation.transactionId !== pendingTxnId)
// Rebase to revert document to what it looked like before the failed mutation
this.rebase([])
}
rebase(incomingMutations: Mutation[]): void {
const oldEdge = this.EDGE
this.EDGE = Mutation.applyAll(this.HEAD, this.submitted.concat(this.pending))
// Copy over rev, since we don't care if it changed, we only care about the content
if (oldEdge !== null && this.EDGE !== null) {
oldEdge._rev = this.EDGE._rev
}
const changed = !isEqual(this.EDGE, oldEdge)
if (changed && this.onRebase) {
this.onRebase(this.EDGE, incomingMutations, this.pending)
}
}
}