sharedb
Version:
JSON OT database backend
265 lines (231 loc) • 9.45 kB
JavaScript
// This contains the master OT functions for the database. They look like
// ot-types style operational transform functions, but they're a bit different.
// These functions understand versions and can deal with out of bound create &
// delete operations.
var types = require('./types');
var ShareDBError = require('./error');
var util = require('./util');
var ERROR_CODE = ShareDBError.CODES;
// Returns an error string on failure. Rockin' it C style.
exports.checkOp = function(op) {
if (op == null || typeof op !== 'object') {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'Op must be an object');
}
if (op.create != null) {
if (typeof op.create !== 'object') {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'Create data must be an object');
}
var typeName = op.create.type;
if (typeof typeName !== 'string') {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'Missing create type');
}
var type = types.map[typeName];
if (type == null || typeof type !== 'object') {
return new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Unknown type');
}
} else if (op.del != null) {
if (op.del !== true) return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'del value must be true');
} else if (!('op' in op)) {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'Missing op, create, or del');
}
if (op.src != null && typeof op.src !== 'string') {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'src must be a string');
}
if (op.seq != null && typeof op.seq !== 'number') {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'seq must be a number');
}
if (
(op.src == null && op.seq != null) ||
(op.src != null && op.seq == null)
) {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'Both src and seq must be set together');
}
if (op.m != null && typeof op.m !== 'object') {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'op.m must be an object or null');
}
};
// Takes in a string (type name or URI) and returns the normalized name (uri)
exports.normalizeType = function(typeName) {
return types.map[typeName] && types.map[typeName].uri;
};
// This is the super apply function that takes in snapshot data (including the
// type) and edits it in-place. Returns an error or null for success.
exports.apply = function(snapshot, op) {
if (typeof snapshot !== 'object') {
return new ShareDBError(ERROR_CODE.ERR_APPLY_SNAPSHOT_NOT_PROVIDED, 'Missing snapshot');
}
if (snapshot.v != null && op.v != null && snapshot.v !== op.v) {
return new ShareDBError(ERROR_CODE.ERR_APPLY_OP_VERSION_DOES_NOT_MATCH_SNAPSHOT, 'Version mismatch');
}
// Create operation
if (op.create) {
if (snapshot.type) return new ShareDBError(ERROR_CODE.ERR_DOC_ALREADY_CREATED, 'Document already exists');
// The document doesn't exist, although it might have once existed
var create = op.create;
var type = types.map[create.type];
if (!type) return new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Unknown type');
try {
snapshot.data = type.create(create.data);
snapshot.type = type.uri;
snapshot.v++;
} catch (err) {
return err;
}
// Delete operation
} else if (op.del) {
snapshot.data = undefined;
snapshot.type = null;
snapshot.v++;
// Edit operation
} else if ('op' in op) {
var err = applyOpEdit(snapshot, op.op);
if (err) return err;
snapshot.v++;
// No-op, and we don't have to do anything
} else {
snapshot.v++;
}
};
function applyOpEdit(snapshot, edit) {
if (!snapshot.type) return new ShareDBError(ERROR_CODE.ERR_DOC_DOES_NOT_EXIST, 'Document does not exist');
if (edit === undefined) return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_PROVIDED, 'Missing op');
var type = types.map[snapshot.type];
if (!type) return new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Unknown type');
if (type.name === 'json0' && Array.isArray(edit)) {
for (var i = 0; i < edit.length; i++) {
var opComponent = edit[i];
if (Array.isArray(opComponent.p)) {
for (var j = 0; j < opComponent.p.length; j++) {
var pathSegment = opComponent.p[j];
if (util.isDangerousProperty(pathSegment)) {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, 'Invalid path segment');
}
}
}
}
}
try {
snapshot.data = type.apply(snapshot.data, edit);
} catch (err) {
return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, err.message);
}
}
exports.transform = function(type, op, appliedOp) {
// There are 16 cases this function needs to deal with - which are all the
// combinations of create/delete/op/noop from both op and appliedOp
if (op.v != null && op.v !== appliedOp.v) {
return new ShareDBError(ERROR_CODE.ERR_OP_VERSION_MISMATCH_DURING_TRANSFORM, 'Version mismatch');
}
if (appliedOp.del) {
if (op.create || 'op' in op) {
return new ShareDBError(ERROR_CODE.ERR_DOC_WAS_DELETED, 'Document was deleted');
}
} else if (
(appliedOp.create && ('op' in op || op.create || op.del)) ||
('op' in appliedOp && op.create)
) {
// If appliedOp.create is not true, appliedOp contains an op - which
// also means the document exists remotely.
return new ShareDBError(ERROR_CODE.ERR_DOC_ALREADY_CREATED, 'Document was created remotely');
} else if ('op' in appliedOp && 'op' in op) {
// If we reach here, they both have a .op property.
if (!type) return new ShareDBError(ERROR_CODE.ERR_DOC_DOES_NOT_EXIST, 'Document does not exist');
if (typeof type === 'string') {
type = types.map[type];
if (!type) return new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Unknown type');
}
try {
op.op = type.transform(op.op, appliedOp.op, 'left');
} catch (err) {
return err;
}
}
if (op.v != null) op.v++;
};
/**
* Apply an array of ops to the provided snapshot.
*
* @param snapshot - a Snapshot object which will be mutated by the provided ops
* @param ops - an array of ops to apply to the snapshot
* @param options - options (currently for internal use only)
* @return an error object if applicable
*/
exports.applyOps = function(snapshot, ops, options) {
options = options || {};
for (var index = 0; index < ops.length; index++) {
var op = ops[index];
if (options._normalizeLegacyJson0Ops) {
try {
normalizeLegacyJson0Ops(snapshot, op);
} catch (error) {
return new ShareDBError(
ERROR_CODE.ERR_OT_LEGACY_JSON0_OP_CANNOT_BE_NORMALIZED,
'Cannot normalize legacy json0 op'
);
}
}
snapshot.v = op.v;
var error = exports.apply(snapshot, op);
if (error) return error;
}
};
exports.transformPresence = function(presence, op, isOwnOp) {
var opError = this.checkOp(op);
if (opError) return opError;
var type = presence.t;
if (typeof type === 'string') {
type = types.map[type];
}
if (!type) return {code: ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, message: 'Unknown type'};
if (!util.supportsPresence(type)) {
return {code: ERROR_CODE.ERR_TYPE_DOES_NOT_SUPPORT_PRESENCE, message: 'Type does not support presence'};
}
if (op.create || op.del) {
presence.p = null;
presence.v++;
return;
}
try {
presence.p = presence.p === null ?
null :
type.transformPresence(presence.p, op.op, isOwnOp);
} catch (error) {
return {code: ERROR_CODE.ERR_PRESENCE_TRANSFORM_FAILED, message: error.message || error};
}
presence.v++;
};
/**
* json0 had a breaking change in https://github.com/ottypes/json0/pull/40
* The change added stricter type checking, which breaks fetchSnapshot()
* when trying to rebuild a snapshot from old, committed ops that didn't
* have this stricter validation. This method fixes up legacy ops to
* pass the stricter validation
*/
function normalizeLegacyJson0Ops(snapshot, json0Op) {
if (snapshot.type !== types.defaultType.uri) return;
var components = json0Op.op;
if (!components) return;
var data = snapshot.data;
// type.apply() makes no guarantees about mutating the original data, so
// we need to clone. However, we only need to apply() if we have multiple
// components, so avoid cloning if we don't have to.
if (components.length > 1) data = util.clone(data);
for (var i = 0; i < components.length; i++) {
var component = components[i];
if (typeof component.lm === 'string') component.lm = +component.lm;
var path = component.p;
var element = data;
for (var j = 0; j < path.length; j++) {
var key = path[j];
// https://github.com/ottypes/json0/blob/73db17e86adc5d801951d1a69453b01382e66c7d/lib/json0.js#L21
if (Object.prototype.toString.call(element) == '[object Array]') path[j] = +key;
// https://github.com/ottypes/json0/blob/73db17e86adc5d801951d1a69453b01382e66c7d/lib/json0.js#L32
else if (element.constructor === Object) path[j] = key.toString();
element = element[key];
}
// Apply to update the snapshot, so we can correctly check the path for
// the next component. We don't need to do this on the final iteration,
// since there's no more ops.
if (i < components.length - 1) data = types.defaultType.apply(data, [component]);
}
}