UNPKG

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