2023-01-13 07:42:29 -05:00
|
|
|
'use strict'
|
|
|
|
|
|
|
|
const _ = require('lodash')
|
|
|
|
const assert = require('check-types').assert
|
2024-01-23 09:14:06 -05:00
|
|
|
const EditOperationTransformer = require('./edit_operation_transformer')
|
2023-01-13 07:42:29 -05:00
|
|
|
|
|
|
|
// Dependencies are loaded at the bottom of the file to mitigate circular
|
|
|
|
// dependency
|
|
|
|
let NoOperation = null
|
|
|
|
let AddFileOperation = null
|
|
|
|
let MoveFileOperation = null
|
|
|
|
let EditFileOperation = null
|
|
|
|
let SetFileMetadataOperation = null
|
|
|
|
|
|
|
|
/**
|
2024-09-20 09:52:23 -04:00
|
|
|
* @import { BlobStore } from "../types"
|
|
|
|
* @import Snapshot from "../snapshot"
|
2023-01-13 07:42:29 -05:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* An `Operation` changes a `Snapshot` when it is applied. See the
|
|
|
|
* {@tutorial OT} tutorial for background.
|
|
|
|
*/
|
|
|
|
class Operation {
|
|
|
|
/**
|
|
|
|
* Deserialize an Operation.
|
|
|
|
*
|
|
|
|
* @param {Object} raw
|
|
|
|
* @return {Operation} one of the subclasses
|
|
|
|
*/
|
|
|
|
static fromRaw(raw) {
|
2024-02-20 08:30:36 -05:00
|
|
|
if ('file' in raw) {
|
2023-01-13 07:42:29 -05:00
|
|
|
return AddFileOperation.fromRaw(raw)
|
|
|
|
}
|
2024-02-26 08:51:03 -05:00
|
|
|
if (
|
|
|
|
'textOperation' in raw ||
|
|
|
|
'commentId' in raw ||
|
|
|
|
'deleteComment' in raw
|
|
|
|
) {
|
2023-01-13 07:42:29 -05:00
|
|
|
return EditFileOperation.fromRaw(raw)
|
|
|
|
}
|
2024-02-20 08:30:36 -05:00
|
|
|
if ('newPathname' in raw) {
|
2023-01-13 07:42:29 -05:00
|
|
|
return new MoveFileOperation(raw.pathname, raw.newPathname)
|
|
|
|
}
|
2024-02-20 08:30:36 -05:00
|
|
|
if ('metadata' in raw) {
|
2023-01-13 07:42:29 -05:00
|
|
|
return new SetFileMetadataOperation(raw.pathname, raw.metadata)
|
|
|
|
}
|
|
|
|
if (_.isEmpty(raw)) {
|
|
|
|
return new NoOperation()
|
|
|
|
}
|
|
|
|
throw new Error('invalid raw operation ' + JSON.stringify(raw))
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Serialize an Operation.
|
|
|
|
*
|
|
|
|
* @return {Object}
|
|
|
|
*/
|
|
|
|
toRaw() {
|
|
|
|
return {}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Whether this operation does nothing when applied.
|
|
|
|
*
|
|
|
|
* @return {Boolean}
|
|
|
|
*/
|
|
|
|
isNoOp() {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* If this Operation references blob hashes, add them to the given Set.
|
|
|
|
*
|
|
|
|
* @param {Set.<String>} blobHashes
|
|
|
|
*/
|
|
|
|
findBlobHashes(blobHashes) {}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* If this operation references any files, load the files.
|
|
|
|
*
|
|
|
|
* @param {string} kind see {File#load}
|
|
|
|
* @param {BlobStore} blobStore
|
2023-08-22 10:48:24 -04:00
|
|
|
* @return {Promise<void>}
|
2023-01-13 07:42:29 -05:00
|
|
|
*/
|
2023-08-22 10:48:24 -04:00
|
|
|
async loadFiles(kind, blobStore) {}
|
2023-01-13 07:42:29 -05:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Return a version of this operation that is suitable for long term storage.
|
|
|
|
* In most cases, we just need to convert the operation to raw form, but if
|
|
|
|
* the operation involves File objects, we may need to store their content.
|
|
|
|
*
|
|
|
|
* @param {BlobStore} blobStore
|
|
|
|
* @return {Promise.<Object>}
|
|
|
|
*/
|
2023-08-22 10:48:24 -04:00
|
|
|
async store(blobStore) {
|
|
|
|
return this.toRaw()
|
2023-01-13 07:42:29 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Apply this Operation to a snapshot.
|
|
|
|
*
|
|
|
|
* The snapshot is modified in place.
|
|
|
|
*
|
|
|
|
* @param {Snapshot} snapshot
|
|
|
|
*/
|
|
|
|
applyTo(snapshot) {
|
|
|
|
assert.object(snapshot, 'bad snapshot')
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Whether this operation can be composed with another operation to produce a
|
|
|
|
* single operation of the same type as this one, while keeping the composed
|
|
|
|
* operation small and logical enough to be used in the undo stack.
|
|
|
|
*
|
|
|
|
* @param {Operation} other
|
|
|
|
* @return {Boolean}
|
|
|
|
*/
|
|
|
|
canBeComposedWithForUndo(other) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Whether this operation can be composed with another operation to produce a
|
|
|
|
* single operation of the same type as this one.
|
|
|
|
*
|
|
|
|
* TODO Moves can be composed. For example, if you rename a to b and then decide
|
|
|
|
* shortly after that actually you want to call it c, we could compose the two
|
|
|
|
* to get a -> c). Edits can also be composed --- see rules in TextOperation.
|
|
|
|
* We also need to consider the Change --- we will need to consider both time
|
|
|
|
* and author(s) when composing changes. I guess that AddFile can also be
|
|
|
|
* composed in some cases --- if you upload a file and then decide it was the
|
|
|
|
* wrong one and upload a new one, we could drop the one in the middle, but
|
|
|
|
* that seems like a pretty rare case.
|
|
|
|
*
|
|
|
|
* @param {Operation} other
|
|
|
|
* @return {Boolean}
|
|
|
|
*/
|
|
|
|
canBeComposedWith(other) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Compose this operation with another operation to produce a single operation
|
|
|
|
* of the same type as this one.
|
|
|
|
*
|
|
|
|
* @param {Operation} other
|
|
|
|
* @return {Operation}
|
|
|
|
*/
|
|
|
|
compose(other) {
|
|
|
|
throw new Error('not implemented')
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Transform takes two operations A and B that happened concurrently and
|
|
|
|
* produces two operations A' and B' (in an array) such that
|
|
|
|
* `apply(apply(S, A), B') = apply(apply(S, B), A')`.
|
|
|
|
*
|
|
|
|
* That is, if one client applies A and then B', they get the same result as
|
|
|
|
* another client who applies B and then A'.
|
|
|
|
*
|
|
|
|
* @param {Operation} a
|
|
|
|
* @param {Operation} b
|
|
|
|
* @return {Operation[]} operations `[a', b']`
|
|
|
|
*/
|
|
|
|
static transform(a, b) {
|
2024-02-20 05:37:27 -05:00
|
|
|
if (a.isNoOp() || b.isNoOp()) return [a, b]
|
2023-01-13 07:42:29 -05:00
|
|
|
|
|
|
|
function transpose(transformer) {
|
|
|
|
return transformer(b, a).reverse()
|
|
|
|
}
|
|
|
|
|
|
|
|
const bIsAddFile = b instanceof AddFileOperation
|
|
|
|
const bIsEditFile = b instanceof EditFileOperation
|
|
|
|
const bIsMoveFile = b instanceof MoveFileOperation
|
|
|
|
const bIsSetFileMetadata = b instanceof SetFileMetadataOperation
|
|
|
|
|
|
|
|
if (a instanceof AddFileOperation) {
|
|
|
|
if (bIsAddFile) return transformAddFileAddFile(a, b)
|
|
|
|
if (bIsMoveFile) return transformAddFileMoveFile(a, b)
|
|
|
|
if (bIsEditFile) return transformAddFileEditFile(a, b)
|
|
|
|
if (bIsSetFileMetadata) return transformAddFileSetFileMetadata(a, b)
|
|
|
|
throw new Error('bad op b')
|
|
|
|
}
|
|
|
|
if (a instanceof MoveFileOperation) {
|
|
|
|
if (bIsAddFile) return transpose(transformAddFileMoveFile)
|
|
|
|
if (bIsMoveFile) return transformMoveFileMoveFile(a, b)
|
|
|
|
if (bIsEditFile) return transformMoveFileEditFile(a, b)
|
|
|
|
if (bIsSetFileMetadata) return transformMoveFileSetFileMetadata(a, b)
|
|
|
|
throw new Error('bad op b')
|
|
|
|
}
|
|
|
|
if (a instanceof EditFileOperation) {
|
|
|
|
if (bIsAddFile) return transpose(transformAddFileEditFile)
|
|
|
|
if (bIsMoveFile) return transpose(transformMoveFileEditFile)
|
|
|
|
if (bIsEditFile) return transformEditFileEditFile(a, b)
|
|
|
|
if (bIsSetFileMetadata) return transformEditFileSetFileMetadata(a, b)
|
|
|
|
throw new Error('bad op b')
|
|
|
|
}
|
|
|
|
if (a instanceof SetFileMetadataOperation) {
|
|
|
|
if (bIsAddFile) return transpose(transformAddFileSetFileMetadata)
|
|
|
|
if (bIsMoveFile) return transpose(transformMoveFileSetFileMetadata)
|
|
|
|
if (bIsEditFile) return transpose(transformEditFileSetFileMetadata)
|
|
|
|
if (bIsSetFileMetadata) return transformSetFileMetadatas(a, b)
|
|
|
|
throw new Error('bad op b')
|
|
|
|
}
|
|
|
|
throw new Error('bad op a')
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Transform each operation in `a` by each operation in `b` and save the primed
|
|
|
|
* operations in place.
|
|
|
|
*
|
|
|
|
* @param {Array.<Operation>} as - modified in place
|
|
|
|
* @param {Array.<Operation>} bs - modified in place
|
|
|
|
*/
|
|
|
|
static transformMultiple(as, bs) {
|
|
|
|
for (let i = 0; i < as.length; ++i) {
|
|
|
|
for (let j = 0; j < bs.length; ++j) {
|
|
|
|
const primes = Operation.transform(as[i], bs[j])
|
|
|
|
as[i] = primes[0]
|
|
|
|
bs[j] = primes[1]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static addFile(pathname, file) {
|
|
|
|
return new AddFileOperation(pathname, file)
|
|
|
|
}
|
|
|
|
|
2024-01-23 09:14:06 -05:00
|
|
|
static editFile(pathname, editOperation) {
|
|
|
|
return new EditFileOperation(pathname, editOperation)
|
2023-01-13 07:42:29 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
static moveFile(pathname, newPathname) {
|
|
|
|
return new MoveFileOperation(pathname, newPathname)
|
|
|
|
}
|
|
|
|
|
|
|
|
static removeFile(pathname) {
|
|
|
|
return new MoveFileOperation(pathname, '')
|
|
|
|
}
|
|
|
|
|
|
|
|
static setFileMetadata(pathname, metadata) {
|
|
|
|
return new SetFileMetadataOperation(pathname, metadata)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
//
|
|
|
|
// Transform
|
|
|
|
//
|
|
|
|
// The way to read these transform functions is that
|
|
|
|
// 1. return_value[0] is the op to be applied after arguments[1], and
|
|
|
|
// 2. return_value[1] is the op to be applied after arguments[0],
|
|
|
|
// in order to arrive at the same project state.
|
|
|
|
//
|
|
|
|
|
|
|
|
function transformAddFileAddFile(add1, add2) {
|
|
|
|
if (add1.getPathname() === add2.getPathname()) {
|
|
|
|
return [Operation.NO_OP, add2] // add2 wins
|
|
|
|
}
|
|
|
|
|
|
|
|
return [add1, add2]
|
|
|
|
}
|
|
|
|
|
|
|
|
function transformAddFileMoveFile(add, move) {
|
|
|
|
function relocateAddFile() {
|
|
|
|
return new AddFileOperation(move.getNewPathname(), add.getFile().clone())
|
|
|
|
}
|
|
|
|
|
|
|
|
if (add.getPathname() === move.getPathname()) {
|
|
|
|
if (move.isRemoveFile()) {
|
|
|
|
return [add, Operation.NO_OP]
|
|
|
|
}
|
|
|
|
return [
|
|
|
|
relocateAddFile(),
|
|
|
|
new MoveFileOperation(add.getPathname(), move.getNewPathname()),
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
if (add.getPathname() === move.getNewPathname()) {
|
|
|
|
return [relocateAddFile(), new MoveFileOperation(move.getPathname(), '')]
|
|
|
|
}
|
|
|
|
|
|
|
|
return [add, move]
|
|
|
|
}
|
|
|
|
|
|
|
|
function transformAddFileEditFile(add, edit) {
|
|
|
|
if (add.getPathname() === edit.getPathname()) {
|
|
|
|
return [add, Operation.NO_OP] // the add wins
|
|
|
|
}
|
|
|
|
|
|
|
|
return [add, edit]
|
|
|
|
}
|
|
|
|
|
|
|
|
function transformAddFileSetFileMetadata(add, set) {
|
|
|
|
if (add.getPathname() === set.getPathname()) {
|
|
|
|
const newFile = add.getFile().clone()
|
|
|
|
newFile.setMetadata(set.getMetadata())
|
|
|
|
return [new AddFileOperation(add.getPathname(), newFile), set]
|
|
|
|
}
|
|
|
|
|
|
|
|
return [add, set]
|
|
|
|
}
|
|
|
|
|
|
|
|
//
|
|
|
|
// This is one of the trickier ones. There are 15 possible equivalence
|
|
|
|
// relationships between our four variables:
|
|
|
|
//
|
|
|
|
// path1, newPath1, path2, newPath2 --- "same move" (all equal)
|
|
|
|
//
|
|
|
|
// path1, newPath1, path2 | newPath2 --- "no-ops" (1)
|
|
|
|
// path1, newPath1, newPath2 | path2 --- "no-ops" (1)
|
|
|
|
// path1, path2, newPath2 | newPath1 --- "no-ops" (2)
|
|
|
|
// newPath1, path2, newPath2 | path1 --- "no-ops" (2)
|
|
|
|
//
|
|
|
|
// path1, newPath1 | path2, newPath2 --- "no-ops" (1 and 2)
|
|
|
|
// path1, path2 | newPath1, newPath2 --- "same move"
|
|
|
|
// path1, newPath2 | newPath1, path2 --- "opposite moves"
|
|
|
|
//
|
|
|
|
// path1, newPath1 | path2 | newPath2 --- "no-ops" (1)
|
|
|
|
// path1, path2 | newPath1 | newPath2 --- "divergent moves"
|
|
|
|
// path1, newPath2 | newPath1 | path2 --- "transitive move"
|
|
|
|
// newPath1, path2 | path1 | newPath2 --- "transitive move"
|
|
|
|
// newPath1, newPath2 | path1 | path2 --- "convergent move"
|
|
|
|
// path2, newPath2 | path1 | newPath1 --- "no-ops" (2)
|
|
|
|
//
|
|
|
|
// path1 | newPath1 | path2 | newPath2 --- "no conflict"
|
|
|
|
//
|
|
|
|
function transformMoveFileMoveFile(move1, move2) {
|
|
|
|
const path1 = move1.getPathname()
|
|
|
|
const path2 = move2.getPathname()
|
|
|
|
const newPath1 = move1.getNewPathname()
|
|
|
|
const newPath2 = move2.getNewPathname()
|
|
|
|
|
|
|
|
// the same move
|
|
|
|
if (path1 === path2 && newPath1 === newPath2) {
|
|
|
|
return [Operation.NO_OP, Operation.NO_OP]
|
|
|
|
}
|
|
|
|
|
|
|
|
// no-ops
|
|
|
|
if (path1 === newPath1 && path2 === newPath2) {
|
|
|
|
return [Operation.NO_OP, Operation.NO_OP]
|
|
|
|
}
|
|
|
|
if (path1 === newPath1) {
|
|
|
|
return [Operation.NO_OP, move2]
|
|
|
|
}
|
|
|
|
if (path2 === newPath2) {
|
|
|
|
return [move1, Operation.NO_OP]
|
|
|
|
}
|
|
|
|
|
|
|
|
// opposite moves (foo -> bar, bar -> foo)
|
|
|
|
if (path1 === newPath2 && path2 === newPath1) {
|
|
|
|
// We can't handle this very well: if we wanted move2 (say) to win, move2'
|
|
|
|
// would have to be addFile(foo) with the content of bar, but we don't have
|
|
|
|
// the content of bar available here. So, we just destroy both files.
|
|
|
|
return [Operation.removeFile(path1), Operation.removeFile(path2)]
|
|
|
|
}
|
|
|
|
|
|
|
|
// divergent moves (foo -> bar, foo -> baz); convention: move2 wins
|
|
|
|
if (path1 === path2 && newPath1 !== newPath2) {
|
|
|
|
return [Operation.NO_OP, Operation.moveFile(newPath1, newPath2)]
|
|
|
|
}
|
|
|
|
|
|
|
|
// convergent move (foo -> baz, bar -> baz); convention: move2 wins
|
|
|
|
if (newPath1 === newPath2 && path1 !== path2) {
|
|
|
|
return [Operation.removeFile(path1), move2]
|
|
|
|
}
|
|
|
|
|
|
|
|
// transitive move:
|
|
|
|
// 1: foo -> baz, 2: bar -> foo (result: bar -> baz) or
|
|
|
|
// 1: foo -> bar, 2: bar -> baz (result: foo -> baz)
|
|
|
|
if (path1 === newPath2 && newPath1 !== path2) {
|
|
|
|
return [
|
|
|
|
Operation.moveFile(newPath2, newPath1),
|
|
|
|
Operation.moveFile(path2, newPath1),
|
|
|
|
]
|
|
|
|
}
|
|
|
|
if (newPath1 === path2 && path1 !== newPath2) {
|
|
|
|
return [
|
|
|
|
Operation.moveFile(path1, newPath2),
|
|
|
|
Operation.moveFile(newPath1, newPath2),
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
// no conflict
|
|
|
|
return [move1, move2]
|
|
|
|
}
|
|
|
|
|
|
|
|
function transformMoveFileEditFile(move, edit) {
|
|
|
|
if (move.getPathname() === edit.getPathname()) {
|
|
|
|
if (move.isRemoveFile()) {
|
|
|
|
// let the remove win
|
|
|
|
return [move, Operation.NO_OP]
|
|
|
|
}
|
|
|
|
return [
|
|
|
|
move,
|
2024-01-23 09:14:06 -05:00
|
|
|
Operation.editFile(move.getNewPathname(), edit.getOperation()),
|
2023-01-13 07:42:29 -05:00
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
if (move.getNewPathname() === edit.getPathname()) {
|
|
|
|
// let the move win
|
|
|
|
return [move, Operation.NO_OP]
|
|
|
|
}
|
|
|
|
|
|
|
|
return [move, edit]
|
|
|
|
}
|
|
|
|
|
|
|
|
function transformMoveFileSetFileMetadata(move, set) {
|
|
|
|
if (move.getPathname() === set.getPathname()) {
|
|
|
|
return [
|
|
|
|
move,
|
|
|
|
Operation.setFileMetadata(move.getNewPathname(), set.getMetadata()),
|
|
|
|
]
|
|
|
|
}
|
|
|
|
// A: mv foo -> bar
|
|
|
|
// B: set bar.x
|
|
|
|
//
|
|
|
|
// A': mv foo -> bar
|
|
|
|
// B': nothing
|
|
|
|
if (move.getNewPathname() === set.getPathname()) {
|
|
|
|
return [move, Operation.NO_OP] // let the move win
|
|
|
|
}
|
|
|
|
return [move, set]
|
|
|
|
}
|
|
|
|
|
|
|
|
function transformEditFileEditFile(edit1, edit2) {
|
|
|
|
if (edit1.getPathname() === edit2.getPathname()) {
|
2024-01-23 09:14:06 -05:00
|
|
|
const primeOps = EditOperationTransformer.transform(
|
|
|
|
edit1.getOperation(),
|
|
|
|
edit2.getOperation()
|
2023-01-13 07:42:29 -05:00
|
|
|
)
|
|
|
|
return [
|
2024-01-23 09:14:06 -05:00
|
|
|
Operation.editFile(edit1.getPathname(), primeOps[0]),
|
|
|
|
Operation.editFile(edit2.getPathname(), primeOps[1]),
|
2023-01-13 07:42:29 -05:00
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
return [edit1, edit2]
|
|
|
|
}
|
|
|
|
|
|
|
|
function transformEditFileSetFileMetadata(edit, set) {
|
|
|
|
// There is no conflict.
|
|
|
|
return [edit, set]
|
|
|
|
}
|
|
|
|
|
|
|
|
function transformSetFileMetadatas(set1, set2) {
|
|
|
|
if (set1.getPathname() === set2.getPathname()) {
|
|
|
|
return [Operation.NO_OP, set2] // set2 wins
|
|
|
|
}
|
|
|
|
return [set1, set2]
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = Operation
|
|
|
|
|
|
|
|
// Work around circular import
|
|
|
|
NoOperation = require('./no_operation')
|
|
|
|
AddFileOperation = require('./add_file_operation')
|
|
|
|
MoveFileOperation = require('./move_file_operation')
|
|
|
|
EditFileOperation = require('./edit_file_operation')
|
|
|
|
SetFileMetadataOperation = require('./set_file_metadata_operation')
|
|
|
|
|
|
|
|
Operation.NO_OP = new NoOperation()
|