UNPKG

@sanity/mutator

Version:

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

354 lines (301 loc) • 10.4 kB
import {isEqual} from 'lodash' import {debug} from './debug' import {Document} from './Document' import {Mutation} from './Mutation' import {SquashingBuffer} from './SquashingBuffer' import {type Doc, type Mut} from './types' const ONE_MINUTE = 1000 * 60 /** * @internal */ export interface CommitHandlerMessage { mutation: Mutation success: () => void failure: () => void cancel: (error: Error) => void } /** * A wrapper for Document that allows the client to gather mutations on the * client side and commit them when it wants to. */ class Commit { mutations: Mutation[] tries: number resolve: () => void reject: (error: Error) => void constructor( mutations: Mutation[], {resolve, reject}: {resolve: () => void; reject: (error: Error) => void}, ) { this.mutations = mutations this.tries = 0 this.resolve = resolve this.reject = reject } apply(doc: Doc | null): Doc | null { return Mutation.applyAll(doc, this.mutations) } squash(doc: Doc | null) { const result = Mutation.squash(doc, this.mutations) result.assignRandomTransactionId() return result } } const mutReducerFn = (acc: Mut[], mut: Mutation): Mut[] => acc.concat(mut.mutations) /** * @internal */ export class BufferedDocument { private mutations: Mutation[] /** * The Document we are wrapping */ document: Document /** * The Document with local changes applied */ LOCAL: Doc | null /** * Commits that are waiting to be delivered to the server */ private commits: Commit[] /** * Local mutations that are not scheduled to be committed yet */ buffer: SquashingBuffer /** * Assignable event handler for when the buffered document applies a mutation */ onMutation?: (message: {mutation: Mutation; document: Doc | null; remote: boolean}) => void /** * Assignable event handler for when a remote mutation happened */ onRemoteMutation?: Document['onRemoteMutation'] /** * Assignable event handler for when the buffered document rebased */ onRebase?: (localDoc: Doc | null, remoteMutations: Mut[], localMutations: Mut[]) => void /** * Assignable event handler for when the document is deleted */ onDelete?: (doc: Doc | null) => void /** * Assignable event handler for when the state of consistency changed */ onConsistencyChanged?: (isConsistent: boolean) => void /** * Assignable event handler for when the buffered document should commit changes */ commitHandler?: (msg: CommitHandlerMessage) => void /** * Whether or not we are currently commiting */ committerRunning = false constructor(doc: Doc | null) { this.buffer = new SquashingBuffer(doc) this.document = new Document(doc) this.document.onMutation = (msg) => this.handleDocMutation(msg) this.document.onRemoteMutation = (mut) => this.onRemoteMutation && this.onRemoteMutation(mut) this.document.onRebase = (edge, remoteMutations, localMutations) => this.handleDocRebase(edge, remoteMutations, localMutations) this.document.onConsistencyChanged = (msg) => this.handleDocConsistencyChanged(msg) this.LOCAL = doc this.mutations = [] this.commits = [] } // Used to reset the state of the local document model. If the model has been inconsistent // for too long, it has probably missed a notification, and should reload the document from the server reset(doc: Doc | null): void { if (doc) { debug('Document state reset to revision %s', doc._rev) } else { debug('Document state reset to being deleted') } this.document.reset(doc) this.rebase([], []) this.handleDocConsistencyChanged(this.document.isConsistent()) } // Add a change to the buffer add(mutation: Mutation): void { if (this.onConsistencyChanged) { this.onConsistencyChanged(false) } debug('Staged local mutation') this.buffer.add(mutation) const oldLocal = this.LOCAL this.LOCAL = mutation.apply(this.LOCAL) if (this.onMutation && oldLocal !== this.LOCAL) { debug('onMutation fired') this.onMutation({ mutation, document: this.LOCAL, remote: false, }) if (this.LOCAL === null && this.onDelete) { this.onDelete(this.LOCAL) } } } // Call when a mutation arrives from Sanity arrive(mutation: Mutation): void { debug('Remote mutation arrived %s -> %s', mutation.previousRev, mutation.resultRev) if (mutation.previousRev === mutation.resultRev) { throw new Error( `Mutation ${mutation.transactionId} has previousRev === resultRev (${mutation.previousRev})`, ) } return this.document.arrive(mutation) } // Submit all mutations in the buffer to be committed commit(): Promise<void> { return new Promise((resolve, reject) => { // Anything to commit? if (!this.buffer.hasChanges()) { resolve() return } debug('Committing local changes') // Collect current staged mutations into a commit and ... const pendingMutations = this.buffer.purge() this.commits.push(new Commit(pendingMutations ? [pendingMutations] : [], {resolve, reject})) // ... clear the table for the next commit. this.buffer = new SquashingBuffer(this.LOCAL) this.performCommits() }) } // Starts the committer that will try to committ all staged commits to the database // by calling the commitHandler. Will keep running until all commits are successfully // committed. performCommits(): void { if (!this.commitHandler) { throw new Error('No commitHandler configured for this BufferedDocument') } if (this.committerRunning) { // We can have only one committer at any given time return } this._cycleCommitter() } // TODO: Error handling, right now retries after every error _cycleCommitter(): void { const commit = this.commits.shift() if (!commit) { this.committerRunning = false return } this.committerRunning = true const squashed = commit.squash(this.LOCAL) const docResponder = this.document.stage(squashed, true) const responder = { success: () => { debug('Commit succeeded') docResponder.success() commit.resolve() // Keep running the committer until no more commits this._cycleCommitter() }, failure: () => { debug('Commit failed') // Re stage commit commit.tries += 1 if (this.LOCAL !== null) { // Only schedule this commit for a retry of the document still exist to avoid looping // indefinitely when the document was deleted from under our noses this.commits.unshift(commit) } docResponder.failure() // Todo: Need better error handling (i.e. propagate to user and provide means of retrying) if (commit.tries < 200) { setTimeout(() => this._cycleCommitter(), Math.min(commit.tries * 1000, ONE_MINUTE)) } }, cancel: (error: Error) => { this.commits.forEach((comm) => comm.reject(error)) // Throw away waiting commits this.commits = [] // Reset back to last known state from content lake and cause a rebase that will // reset the view in the form this.reset(this.document.HEAD) // Clear the buffer of recent mutations this.buffer = new SquashingBuffer(this.LOCAL) // Stop the committer loop this.committerRunning = false }, } debug('Posting commit') if (this.commitHandler) { this.commitHandler({ mutation: squashed, success: responder.success, failure: responder.failure, cancel: responder.cancel, }) } } handleDocRebase(edge: Doc | null, remoteMutations: Mutation[], localMutations: Mutation[]): void { this.rebase(remoteMutations, localMutations) } handleDocumentDeleted(): void { debug('Document deleted') // If the document was just deleted, fire the onDelete event with the absolutely latest // version of the document before someone deleted it so that the client may revive the // document in the last state the user saw it, should they so desire. if (this.LOCAL !== null && this.onDelete) { this.onDelete(this.LOCAL) } this.commits = [] this.mutations = [] } handleDocMutation(msg: {mutation: Mutation; document: Doc | null; remote: boolean}): void { // If we have no local changes, we can just pass this on to the client if (this.commits.length === 0 && !this.buffer.hasChanges()) { debug('Document mutated from remote with no local changes') this.LOCAL = this.document.EDGE this.buffer = new SquashingBuffer(this.LOCAL) if (this.onMutation) { this.onMutation(msg) } return } debug('Document mutated from remote with local changes') // If there are local edits, and the document was deleted, we need to purge those local edits now if (this.document.EDGE === null) { this.handleDocumentDeleted() } // We had local changes, so need to signal rebase this.rebase([msg.mutation], []) } rebase(remoteMutations: Mutation[], localMutations: Mutation[]): void { debug('Rebasing document') if (this.document.EDGE === null) { this.handleDocumentDeleted() } const oldLocal = this.LOCAL this.LOCAL = this.commits.reduce((doc, commit) => commit.apply(doc), this.document.EDGE) this.LOCAL = this.buffer.rebase(this.LOCAL) // Copy over rev, since we don't care if it changed, we only care about the content if (oldLocal !== null && this.LOCAL !== null) { oldLocal._rev = this.LOCAL._rev } const changed = !isEqual(this.LOCAL, oldLocal) if (changed && this.onRebase) { this.onRebase( this.LOCAL, remoteMutations.reduce(mutReducerFn, []), localMutations.reduce(mutReducerFn, []), ) } } handleDocConsistencyChanged(isConsistent: boolean): void { if (!this.onConsistencyChanged) { return } const hasLocalChanges = this.commits.length > 0 || this.buffer.hasChanges() if (isConsistent && !hasLocalChanges) { this.onConsistencyChanged(true) } if (!isConsistent) { this.onConsistencyChanged(false) } } }