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