Comparing version 3.2.1 to 4.0.0
@@ -1,2 +0,2 @@ | ||
const nono = ['type', 'recps', 'tangle', 'tombstone', 'allowPublic'] | ||
const nono = ['type', 'recps', 'tangle', 'tombstone', 'allowPublic', 'key', 'states', 'conflictFields'] | ||
@@ -3,0 +3,0 @@ module.exports = { |
120
index.js
@@ -7,6 +7,6 @@ const Strategy = require('@tangle/strategy') | ||
const isValidSpec = require('./lib/is-valid-spec') | ||
const IsValidMsg = require('./lib/is-valid-msg') | ||
const IsValidInput = require('./lib/is-valid-input') | ||
const IsValidMsg = require('./lib/is-valid-msg') | ||
const ResolveTangle = require('./lib/resolve-tangle') | ||
const handlePublish = require('./lib/handle-publish') | ||
const HandlePublish = require('./lib/handle-publish') | ||
const { merge, isObject, pick, defaultGetTransformation } = require('./lib/util') | ||
@@ -17,7 +17,12 @@ | ||
module.exports = class CRUT { | ||
constructor (server, spec) { | ||
checkServerDeps(server) | ||
this.server = server | ||
constructor (ssb, spec, opts = {}) { | ||
checkServerDeps(ssb) | ||
checkReservedValues(spec) | ||
checkOpts(opts) | ||
checkReservedValues(spec) | ||
const { | ||
publish = ssb.publish, | ||
feedId = ssb.id | ||
} = opts | ||
this.spec = merge( | ||
@@ -42,8 +47,12 @@ { | ||
if (!isValidSpec(this.spec)) throw isValidSpec.error | ||
this.spec.props = new Strategy(this.spec.props) | ||
this.spec.isRoot = IsValidMsg.root(this.spec) | ||
this.spec.isUpdate = IsValidMsg.update(this.spec) | ||
this.strategy = new Strategy(this.spec.props) | ||
this.isValidProps = IsValidInput('props', this.spec.props._composition) | ||
this.resolveTangle = ResolveTangle(server, this.spec) | ||
this.isRoot = IsValidMsg.root(this) | ||
this.isUpdate = IsValidMsg.update(this) | ||
this.isValidProps = IsValidInput('props', this.spec.props) | ||
this.resolveTangle = ResolveTangle(ssb, this) | ||
this.handlePublish = HandlePublish({ crut: this, publish, feedId, getFeedState: ssb.getFeedState }) | ||
if (this.spec.arbitraryRoot && ssb.tribes) this.getTribe = ssb.tribes.get | ||
} | ||
@@ -66,5 +75,5 @@ | ||
try { | ||
propsT = this.spec.props.mapFromInput( | ||
this.spec.props.identity(), | ||
props | ||
propsT = this.strategy.mapFromInput( | ||
props, | ||
[this.strategy.identity()] | ||
) | ||
@@ -84,8 +93,13 @@ } catch (e) { return cb(e) } | ||
const context = { | ||
accT: this.spec.props.identity() | ||
// graph: ?? should there be a graph here? | ||
// we mock a an empty initial transformation state so we can check `isValidNextStep` | ||
// on the initial step (root message) | ||
const tangleContext = { | ||
tips: [{ | ||
key: null, // DNE | ||
T: this.strategy.identity() | ||
}], | ||
graph: null // DNE | ||
} | ||
handlePublish(this.server, this.spec, context, content, allowPublic, cb) | ||
this.handlePublish(tangleContext, content, allowPublic, cb) | ||
} | ||
@@ -109,7 +123,37 @@ | ||
), | ||
states: tangle.tips | ||
.map(({ key, T }) => ({ key, ...this.spec.props.mapToOutput(T) })) | ||
.map(({ key, T }) => ({ key, ...this.strategy.mapToOutput(T) })), | ||
conflictFields: [] | ||
} | ||
let bestGuessState | ||
// The transformation props are also spread into result (from states). | ||
// If there is only one state we can just use that (without it's key): | ||
if (result.states.length === 1) bestGuessState = { ...result.states[0] } | ||
// However, if there are multiple states we first try to automatically merge them. | ||
else { | ||
const dummyNode = { | ||
// key: '%dummyMessageId', | ||
previous: tangle.tips.map(({ key, T }) => key), | ||
data: this.strategy.identity() | ||
// author: '%dummyMessageAuthor', | ||
// sequence: (sequence || 0) + 1 | ||
} | ||
// If we can automatically merge the tips, then the best guess of state is the merged tips | ||
if (this.strategy.isValidMerge(tangle.graph, dummyNode)) { | ||
const T = this.strategy.merge(tangle.graph, dummyNode) | ||
bestGuessState = this.strategy.mapToOutput(T) | ||
} // eslint-disable-line | ||
// Otherwise, we fall back to the tip with the latest entry | ||
else { | ||
bestGuessState = { ...result.states[0] } | ||
result.conflictFields = this.strategy.isValidMerge.fields | ||
} | ||
} | ||
if ('key' in bestGuessState) delete bestGuessState.key | ||
// Spread the bestGuessState props into result | ||
Object.assign(result, bestGuessState) | ||
cb(null, result) | ||
@@ -129,13 +173,10 @@ }) | ||
const tip = tangle.tips[0] | ||
// NOTE <<< need to change this to support merging! | ||
const data = this.strategy.mapFromInput( | ||
props, | ||
tangle.tips.map(tip => tip.T) // currentTips | ||
) | ||
const content = { | ||
type: this.spec.type, | ||
...this.spec.props.mapFromInput( | ||
tip.T, | ||
props | ||
), | ||
...data, | ||
recps: tangle.root.value.content.recps, | ||
@@ -145,3 +186,3 @@ tangles: { | ||
root: id, | ||
previous: [tip.key] | ||
previous: tangle.tips.map(tip => tip.key) | ||
} | ||
@@ -151,7 +192,3 @@ } | ||
const context = { | ||
accT: tip.T, | ||
graph: tangle.graph | ||
} | ||
handlePublish(this.server, this.spec, context, content, allowPublic, cb) | ||
this.handlePublish(tangle, content, allowPublic, cb) | ||
}) | ||
@@ -178,3 +215,3 @@ } | ||
this.server.tribes.get(groupId, (err, info) => { | ||
this.getTribe(groupId, (err, info) => { | ||
if (err) return cb(err) | ||
@@ -190,3 +227,3 @@ this.update(info.root, props, cb) | ||
this.server.tribes.get(groupId, (err, info) => { | ||
this.getTribe(groupId, (err, info) => { | ||
if (err) return cb(err) | ||
@@ -202,3 +239,3 @@ this.read(info.root, cb) | ||
this.server.tribes.get(groupId, (err, info) => { | ||
this.getTribe(groupId, (err, info) => { | ||
if (err) return cb(err) | ||
@@ -215,3 +252,3 @@ this.tombstone(info.root, props, cb) | ||
else if (key === 'allowPublic') acc.allowPublic = value | ||
else if (key in props._composition) acc.props[key] = value | ||
else if (key in props) acc.props[key] = value | ||
else if (key in staticProps) acc.staticProps[key] = value | ||
@@ -242,1 +279,8 @@ else acc.unknown[key] = value | ||
} | ||
function checkOpts (opts) { | ||
if (opts.publish) { | ||
if (typeof opts.publish !== 'function') throw new Error('opts.publish must be a function') | ||
if (typeof opts.feedId !== 'string') throw new Error('opts.feedId must be provided if opts.publish is used') | ||
} | ||
} |
const stringify = require('fast-json-stable-stringify') | ||
const { createHash } = require('crypto') | ||
const { defaultGetTransformation, getCanonicalContent } = require('./util') | ||
const { defaultGetTransformation, getCanonicalContent, MsgToNode } = require('./util') | ||
module.exports = function handlePublish (ssb, spec, context, content, allowPublic, cb) { | ||
const isRoot = content.tangles[spec.tangle].previous === null | ||
module.exports = function HandlePublish ({ crut, publish, feedId, getFeedState }) { | ||
const { spec, isRoot, isUpdate } = crut | ||
const msgToNode = MsgToNode(spec) | ||
if (isRoot) { | ||
if (!spec.isRoot(content)) return cb(new Error(spec.isRoot.errorsString)) | ||
} else { | ||
if (!spec.isUpdate(content)) return cb(new Error(spec.isUpdate.errorsString)) | ||
} | ||
return function handlePublish (tangleContext, content, allowPublic, cb) { | ||
const previous = content.tangles[spec.tangle].previous | ||
const isRootContent = previous === null | ||
const dummyMsg = buildDummyMsg(ssb, content) | ||
if (isRootContent) { | ||
if (!isRoot(content)) return cb(new Error(isRoot.errorsString)) | ||
} else { | ||
if (!isUpdate(content)) return cb(new Error(isUpdate.errorsString)) | ||
} | ||
// ensure results of getTransformation still conform to schema | ||
// so we can guarentee reduce will work | ||
if (spec.getTransformation !== defaultGetTransformation) { | ||
const checkSum = hash(content) | ||
buildDummyMsg(content, (err, dummyMsg) => { | ||
err = err || findGetTransformationError(content, dummyMsg, isRootContent) | ||
// ensure results of getTransformation still conform to schema | ||
// so we can guarentee reduce will work | ||
if (err) return cb(err) | ||
// we map msg into canonical form of content that we can use to validate with isRoot/ isUpdate | ||
const canonicalContent = getCanonicalContent(dummyMsg, spec.getTransformation) | ||
const dummyNode = msgToNode(dummyMsg) | ||
// if this is a merge update, check if it would be a valid merge. | ||
if (previous && previous.length > 1) { | ||
if (!crut.strategy.isValidMerge(tangleContext.graph, dummyNode)) { | ||
const error = crut.strategy.isValidMerge.error | ||
if (!error) return cb(invalidMergeError()) | ||
// check to see if getTransformation has mutated the content we're about to publish | ||
if (hash(content) !== checkSum) return cb(new Error('getTransformation mutated content about to be published')) | ||
error.conflictFields = crut.strategy.isValidMerge.fields | ||
return cb(error) | ||
} | ||
} | ||
// check is whether it's a valid next step (according to custom function) | ||
if (!spec.isValidNextStep(tangleContext, dummyNode)) { | ||
return cb(spec.isValidNextStep.error || invalidNextStepError(isRootContent)) | ||
} | ||
if (isRoot) { | ||
if (!spec.isRoot(canonicalContent)) return cb(new Error(spec.isRoot.errorsString)) | ||
} else { | ||
if (!spec.isUpdate(canonicalContent)) return cb(new Error(spec.isUpdate.errorsString)) | ||
} | ||
publish(guard(content, allowPublic), (err, msg) => { | ||
if (err) return cb(err) | ||
cb(null, msg.key) | ||
}) | ||
}) | ||
} | ||
ssb.getFeedState(ssb.id, (err, { sequence } = {}) => { | ||
if (err) return cb(err) | ||
function buildDummyMsg (content, cb) { | ||
getFeedState(feedId, (err, { sequence } = {}) => { | ||
if (err) return cb(err) | ||
dummyMsg.value.sequence = (sequence || 0) + 1 // NEXT message, so +1 | ||
cb(null, { | ||
key: '%dummyMessageId', // trick ssb-msg-content | ||
value: { | ||
sequence: (sequence || 0) + 1, | ||
timestamp: Date.now(), | ||
author: feedId, | ||
content, | ||
// NOTE we trust the programmer not to mutate the content with getTransformation | ||
meta: { | ||
dummyMsg: true | ||
} | ||
} | ||
}) | ||
}) | ||
} | ||
if (!spec.isValidNextStep(context, dummyMsg)) { | ||
const err = isRoot | ||
? 'Invalid root message, failed isValidNextStep, publish aborted' | ||
: 'Invalid update message, failed isValidNextStep, publish aborted' | ||
return cb(spec.isValidNextStep.error || new Error(err)) | ||
} | ||
function findGetTransformationError (content, dummyMsg, isRootContent) { | ||
if (spec.getTransformation === defaultGetTransformation) return | ||
// WIP I think should be able to hand in custom 'publish' method: | ||
// new Crut(ssb, spec, { publish }) | ||
// - we do this so that we can customise what exactly comes back if we want (and whether things hit db) | ||
const checkSum = hash(content) | ||
// unsolved problems: | ||
// - extract recps-guard handler as an example | ||
// - what to do about steps which assume ssb.id (for author, and feed you're validating against)? | ||
ssb.publish(guard(content, allowPublic), (err, msg) => { | ||
if (err) return cb(err) | ||
// we map msg into canonical form of content that we can use to validate with isRoot/ isUpdate | ||
const canonicalContent = getCanonicalContent(dummyMsg, spec.getTransformation) | ||
cb(null, msg.key) | ||
}) | ||
}) | ||
if (isRootContent) { | ||
if (!isRoot(canonicalContent)) return new Error(isRoot.errorsString) | ||
} else { | ||
if (!isUpdate(canonicalContent)) return new Error(isUpdate.errorsString) | ||
} | ||
// check to see if getTransformation has mutated the content we're about to publish | ||
if (hash(content) !== checkSum) return new Error('getTransformation mutated content about to be published') | ||
} | ||
} | ||
@@ -69,16 +96,2 @@ | ||
function buildDummyMsg (ssb, content) { | ||
return { | ||
key: '%dummy', // trick ssb-msg-content | ||
value: { | ||
sequence: 1, | ||
timestamp: Date.now(), | ||
author: ssb.id, | ||
content, | ||
// NOTE we trust the programmer not to mutate the content with getTransformation | ||
dummyMsg: true | ||
} | ||
} | ||
} | ||
function guard (content, allowPublic) { | ||
@@ -90,1 +103,12 @@ // this is for ssb-recps-guard | ||
} | ||
function invalidNextStepError (isRoot = true) { | ||
return new Error(isRoot | ||
? 'Invalid root message, failed isValidNextStep, publish aborted' | ||
: 'Invalid update message, failed isValidNextStep, publish aborted' | ||
) | ||
} | ||
function invalidMergeError () { | ||
return new Error('Invalid message, failed merge, publish aborted') | ||
} |
const { isObject } = require('./util') | ||
module.exports = function IsValidInput (label, allowed) { | ||
// NOTE allowed here is an object | ||
// label *String* | ||
// allowed *Object* | ||
const isValidInput = function isValidInput (input) { | ||
@@ -6,0 +7,0 @@ isValidInput.error = null |
const Validator = require('is-my-ssb-valid') | ||
const definitions = require('ssb-schema-definitions')() | ||
function buildIsRoot ({ type, typePattern, tangle, staticProps, props, hooks }) { | ||
function buildIsRoot ({ spec, strategy }) { | ||
const { typePattern, tangle, staticProps, hooks } = spec | ||
const { properties } = strategy.schema | ||
const schema = { | ||
@@ -12,3 +15,3 @@ type: 'object', | ||
...staticProps, | ||
...props.schema.properties, | ||
...properties, | ||
@@ -34,3 +37,6 @@ tangles: { | ||
function buildIsUpdate ({ type, typePattern, tangle, props, hooks }) { | ||
function buildIsUpdate ({ spec, strategy }) { | ||
const { typePattern, tangle, hooks } = spec | ||
const { properties } = strategy.schema | ||
const schema = { | ||
@@ -42,3 +48,3 @@ type: 'object', | ||
...props.schema.properties, | ||
...properties, | ||
@@ -45,0 +51,0 @@ tangles: { |
@@ -19,10 +19,2 @@ const Strategy = require('@tangle/strategy') | ||
// TODO add checks for restricted keys in props, staticProps' | ||
// - recps | ||
// - type | ||
// - attendees ... (props only) | ||
// - staticProps and props cannot overlap | ||
// | ||
// tangle !== group | ||
/* Required */ | ||
@@ -57,2 +49,6 @@ | ||
if (isProblem) errors.push('spec.staticProps were malformed') | ||
// check staticProps don't collide with props | ||
const duplicates = Object.keys(staticProps).filter(staticProp => staticProp in props) | ||
if (duplicates.length) errors.push(`props and staticProps may not have the same name [${duplicates}]`) | ||
} | ||
@@ -59,0 +55,0 @@ |
@@ -1,15 +0,17 @@ | ||
const pull = require('pull-stream') | ||
const Reduce = require('@tangle/reduce') | ||
const { getCanonicalContent } = require('./util') | ||
module.exports = function ResolveTangle (server, spec) { | ||
// NOTE: we only care if the messages we're ingesting pass validators | ||
// *after* they have been mapped by getTransformation (raw msg could have been quite malformed) | ||
const isRoot = msg => spec.isRoot(getCanonicalContent(msg, spec.getTransformation)) | ||
const isUpdate = msg => spec.isUpdate(getCanonicalContent(msg, spec.getTransformation)) | ||
const GetRoot = require('./get-root') | ||
const GetUpdates = require('./get-updates') | ||
const { MsgToNode } = require('./util') | ||
const getBacklinks = msg => msg.value.content.tangles[spec.tangle].previous | ||
const isDB2 = Boolean(server.db) | ||
module.exports = function ResolveTangle (ssb, crut) { | ||
const { spec, strategy } = crut | ||
const msgToNode = MsgToNode(spec) | ||
const getRoot = GetRoot(ssb, crut) | ||
const getUpdates = GetUpdates(ssb, crut) | ||
return function resolveTangle (id, cb) { | ||
// First, the root message and it's update messages are fetched from the ssb | ||
getRoot(id, (err, root) => { | ||
@@ -20,19 +22,13 @@ if (err) return cb(err) | ||
if (err) return cb(err) | ||
// First, the root message and it's update messages are fetched from the ssb | ||
const msgs = [root, ...updates] | ||
const msgs = [root, ...updates] | ||
const reduce = new Reduce(spec.props, { | ||
nodes: msgs, | ||
getTransformation: spec.getTransformation, | ||
getBacklinks, | ||
isValid: spec.isValidNextStep | ||
const reduce = new Reduce(strategy, { | ||
nodes: msgs.map(msgToNode), | ||
isValidNextStep: spec.isValidNextStep | ||
}) | ||
const timestamps = msgs.reduce((timestamps, m) => { | ||
timestamps[m.key] = m.value.timestamp | ||
return timestamps | ||
}, {}) | ||
const tips = Object.entries(reduce.state) | ||
.map(([key, T]) => ({ key, T })) | ||
.sort((A, B) => timestamps[B.key] - timestamps[A.key]) | ||
.sort(TimestampComparer(msgs)) | ||
@@ -43,135 +39,17 @@ cb(null, { root, tips, graph: reduce.graph }) | ||
} | ||
} | ||
function getRoot (id, cb) { | ||
server.get({ id, private: true, meta: true }, (err, root) => { | ||
if (err) return cb(err) | ||
function TimestampComparer (msgs) { | ||
const timestamps = msgs.reduce((timestamps, m) => { | ||
timestamps[m.key] = m.value.timestamp | ||
return timestamps | ||
}, {}) | ||
handleArbitraryRoot(root, (err, root) => { | ||
if (err) return cb(err) | ||
let result | ||
return (A, B) => { | ||
result = timestamps[B.key] - timestamps[A.key] | ||
if (result !== 0) return result | ||
if (!isRoot(root)) { | ||
return cb(new Error(`not a valid ${spec.type}, ${spec.isRoot.errorsString}`)) | ||
} | ||
// HACK - ensures there's an authors field on the root message | ||
// this is to work around ssb-crut-authors which assumes all root messages must set | ||
// authors. Old ssb-profile records did not do this so resolving them would break | ||
// TODO - remove this hack (e.g. move to opts.getTransformation in ssb-profile) | ||
if (isEmpty(root.value.content.authors)) { | ||
root.value.content.authors = { | ||
[root.value.author]: { [root.value.sequence]: 1 } | ||
} | ||
} | ||
cb(null, root) | ||
}) | ||
}) | ||
return A.key < B.key ? -1 : 1 | ||
} | ||
function handleArbitraryRoot (rootMsg, cb) { | ||
if (!spec.arbitraryRoot) return cb(null, rootMsg) | ||
// build a mock root message with needed details | ||
const mock = { | ||
key: rootMsg.key, | ||
value: { | ||
author: rootMsg.value.author, | ||
content: { | ||
type: spec.type, | ||
tangles: { | ||
[spec.tangle]: { root: null, previous: null } | ||
} | ||
// recps decided below | ||
} | ||
} | ||
} | ||
const { type, recps } = rootMsg.value.content | ||
if (recps) { | ||
mock.value.content.recps = recps | ||
cb(null, mock) | ||
} // eslint-disable-line | ||
// NOTE group/init messages | ||
// - are manually encrypted, and do not have content.recps field | ||
// - historically could not be decrypted by the author! (ammended now) | ||
else if (type === 'group/init' || isLegacyGroupInit(rootMsg.value.content)) { | ||
server.tribes.list({ subtribes: true }, (err, groupIds) => { | ||
if (err) return cb(err) | ||
pull( | ||
pull.values(groupIds), | ||
// TODO modify server.tribes.get to include groupId in returned result by default: | ||
// pull.asyncMap(server.tribes.get), | ||
pull.asyncMap((groupId, cb) => { | ||
server.tribes.get(groupId, (err, info) => { | ||
if (err) cb(err) | ||
else cb(null, { groupId, ...info }) | ||
}) | ||
}), | ||
pull.filter(groupInfo => groupInfo.root === rootMsg.key), | ||
pull.take(1), | ||
pull.collect((err, groupInfos) => { | ||
if (err) return cb(err) | ||
mock.value.content.recps = [groupInfos[0].groupId] | ||
mock.value.content.tangles.group = { root: null, previous: null } | ||
cb(null, mock) | ||
}) | ||
) | ||
}) | ||
} // eslint-disable-line | ||
else cb(null, mock) | ||
} | ||
function getUpdates (id, cb) { | ||
// a query which gets all update messages to the root message | ||
let source | ||
if (isDB2) { | ||
const { where, and, slowEqual, type, toPullStream } = server.db.operators | ||
source = server.db.query( | ||
where( | ||
and( | ||
type(spec.type), | ||
slowEqual(`value.content.tangles.${spec.tangle}.root`, id) | ||
) | ||
), | ||
toPullStream() | ||
) | ||
} else { | ||
const query = [{ | ||
$filter: { | ||
dest: id, | ||
value: { | ||
content: { | ||
type: spec.type, | ||
tangles: { | ||
[spec.tangle]: { root: id } | ||
} | ||
} | ||
} | ||
} | ||
}] | ||
source = server.backlinks.read({ query }) | ||
} | ||
pull( | ||
source, | ||
pull.filter(isUpdate), | ||
pull.collect(cb) | ||
) | ||
} | ||
} | ||
function isEmpty (obj) { | ||
if (obj == null) return true | ||
if (!Object.keys(obj).length) return true | ||
return false | ||
} | ||
function isLegacyGroupInit (content) { | ||
return ( | ||
typeof content === 'string' && | ||
content.endsWith('.box2') && | ||
content.length <= 209 // max historical size | ||
) | ||
} |
@@ -42,11 +42,24 @@ const merge = require('lodash.merge') | ||
tangles: msg.value.content.tangles, | ||
recps: msg.value.content.recps | ||
tangles: msg.value.content.tangles | ||
// recps ? | ||
} | ||
if (msg.value.content.recps) content.recps = msg.value.content.recps | ||
if (!content.recps) delete content.recps | ||
return content | ||
} | ||
function MsgToNode (spec) { | ||
return function msgToNode (msg) { | ||
return { | ||
key: msg.key, | ||
previous: msg.value.content.tangles[spec.tangle].previous, | ||
data: spec.getTransformation(msg), | ||
// Note: getTransformation may include props that are not in the strategy | ||
author: msg.value.author, | ||
sequence: msg.value.sequence | ||
} | ||
} | ||
} | ||
module.exports = { | ||
@@ -57,3 +70,4 @@ isObject, | ||
defaultGetTransformation, | ||
getCanonicalContent | ||
getCanonicalContent, | ||
MsgToNode | ||
} |
{ | ||
"name": "ssb-crut", | ||
"version": "3.2.1", | ||
"version": "4.0.0", | ||
"description": "easy CRUT methods for secure scuttlebutt", | ||
@@ -10,5 +10,5 @@ "main": "index.js", | ||
"scripts": { | ||
"test": "npm run test:js:db1 && npm run test:js:db2 && npm run test:only && npm run lint", | ||
"test": "npm run test:js:db1 && npm run test:js:db2 && npm run lint && npm run test:only ", | ||
"test:js:db1": "tape 'test/**/*.test.js' | tap-spec", | ||
"test:js:db2": "DB2=1 tape 'test/**/*.test.js' | tap-spec", | ||
"test:js:db2": "cross-env DB2=1 tape 'test/**/*.test.js' | tap-spec", | ||
"test:only": "if grep -r --exclude-dir=node_modules --exclude-dir=.git --color 'test\\.only' ; then exit 1; fi", | ||
@@ -33,5 +33,4 @@ "lint": "standard --fix" | ||
"dependencies": { | ||
"@tangle/overwrite": "^2.0.1", | ||
"@tangle/reduce": "^3.1.0", | ||
"@tangle/strategy": "^2.0.1", | ||
"@tangle/reduce": "^5.0.0", | ||
"@tangle/strategy": "^4.1.0", | ||
"fast-json-stable-stringify": "^2.1.0", | ||
@@ -44,5 +43,7 @@ "is-my-ssb-valid": "^1.1.0", | ||
"devDependencies": { | ||
"@tangle/complex-set": "^2.0.0", | ||
"@tangle/linear-append": "^1.0.0", | ||
"@tangle/simple-set": "^2.0.0", | ||
"@tangle/complex-set": "^3.0.1", | ||
"@tangle/linear-append": "^2.0.0", | ||
"@tangle/overwrite": "^3.0.0", | ||
"@tangle/simple-set": "^3.0.0", | ||
"cross-env": "^7.0.3", | ||
"scuttle-testbot": "^1.8.0", | ||
@@ -49,0 +50,0 @@ "ssb-backlinks": "^2.1.1", |
114
README.md
@@ -90,6 +90,6 @@ # ssb-crut | ||
### `new CRUT(ssb, spec) => crut` | ||
### `new CRUT(ssb, spec, opts?) => crut` | ||
Takes `ssb`, a scuttelbutt server instance and a `spec` and returns an crut instance | ||
with methods for mutable records. | ||
Takes `ssb`, a scuttlebutt server instance, a `spec` and an optional | ||
opts and returns an crut instance with methods for mutable records. | ||
@@ -100,3 +100,3 @@ A `spec` is an Object with properties: | ||
- defines how the mutable parts of the record will behaved. | ||
- each property is expected to be an instance of a tangle strategy (e.g. `@tangle/simple-set`) | ||
- each property is expected to be an instance of a tangle strategy (e.g. `@tangle/simple-set`) | ||
- reserved props: `['type', 'recps', 'tangle', 'tombstone']` | ||
@@ -123,10 +123,38 @@ | ||
- a function run before writing and during reading to confirm the validity of message extending the tangle | ||
- signature: `fn(context, msg) => Boolean` where | ||
- `context = { accT, graph }` where `accT` is the accumulated transform for the position immediately before this message in the tangle, and `graph` is a `@tangle/graph` instance for the tangle so far. | ||
- `msg` the message being assessed | ||
- signature: `fn(tangleContext, node) => Boolean` where | ||
- `tangleContext = { tips, graph }` where: | ||
- `tips` *Array* represents the accumulated transform for the position immediately before this message in the tangle, `[{ key, T }]` | ||
- `graph` is a `@tangle/graph` instance for the tangle so far. | ||
- `node` the node being assessed is of form: | ||
```js | ||
{ | ||
key: MessageId, | ||
previous: ['%sd4kasDD...'], | ||
data: { | ||
title: { set: 'spec gathering' }, | ||
attendees: { mix: 1, luandro: 1 } | ||
}, | ||
author: '@ye+4das...', | ||
sequence: 132 | ||
} | ||
``` | ||
- used by: | ||
- `create` to check the message about to be published is valid. In this case `accT = I`, the (empty) identity transform. | ||
- `update` to check the message about to be published is valid given the existing tangle state it would extend | ||
- `read` to determine which messages are valid to include in reducing | ||
- NOTE - in `create` you don't have access to `context.graph`, as there's no graph yet | ||
- `create` to check the message about to be published is valid. In this case `accT = I`, the (empty) identity transform. | ||
- `update` to check the message about to be published is valid given the existing tangle state it would extend | ||
- `read` to determine which messages are valid to include in reducing | ||
- NOTE - in `create` you don't have access to `tangleContext.graph`, as there's no graph yet | ||
- ADVANCED - if you want to provide a detailed error, you can attach errors to your validator like so: | ||
```js | ||
function isValidNextStep (tangleContext, node) { | ||
isValidNextStep.error = null | ||
const isValid = ... // your logic | ||
if (isValid) return true | ||
else { | ||
isValidNextStep.error = new Error('your detailed error message') | ||
return false | ||
} | ||
} | ||
``` | ||
- `spec.hooks` *Object* with properties: | ||
@@ -148,2 +176,6 @@ - `isRoot` *Array* a collection of validator functions which each root message must pass to proceed | ||
`opts` can have the following properties: | ||
- `publish(content, cb)`, a custom publish function instead of the default ssb.publish. Could be `publishAs` when using ssb-db2 to publish the content as another feedId | ||
- `feedId`, the feedId to publish as. Defaults to ssb.id. | ||
### `crut.create(allProps, cb)` | ||
@@ -153,3 +185,3 @@ | ||
- `allProps` *Object* | ||
- `allProps` *Object* | ||
- none/ some/ all of the properties declared in `spec.props` | ||
@@ -169,4 +201,3 @@ - none/ some/ all of the properties declared in `spec.staticProps` | ||
A tangle here is a collection of messages linked in a directed acyclic graph. | ||
Each of thee messages contains an "operational transform" which is an | ||
instuction about how to update the record state. | ||
Each of thee messages contains some transformation(s) which are an instuction about how to update the record state. | ||
@@ -190,15 +221,17 @@ Transformations are concatenated (added up) while traversing the graph. | ||
{ | ||
key: A, // the key of the tangle root message | ||
key: A, // the key of the tangle root message | ||
type, | ||
...staticProps, // any staticProp values | ||
...staticProps, // any staticProp values | ||
...props // best guess of state (auto-merged states or if conflict the most recent state) | ||
states: [ | ||
{ | ||
key: D, // key of tangle tip message | ||
...props // reified state of props for this tangle tip | ||
key: D, // key of tangle tip message | ||
...props // reified state of props for this tangle tip | ||
}, | ||
{ | ||
key: B, // key of tangle tip message | ||
...props // reified state of props for this tangle tip | ||
key: B, // key of tangle tip message | ||
...props // reified state of props for this tangle tip | ||
} | ||
} | ||
}, | ||
conflictFields: [] // names of trouble fields if there is a conflict | ||
} | ||
@@ -210,2 +243,5 @@ ``` | ||
For convenience the states are automatically merged and spread into the result. | ||
If the states are in conflict then the first state is used as a 'best guess' | ||
The state of the props returned are "riefied" (meaning _has been made real_), | ||
@@ -219,2 +255,3 @@ because often the transformation format is optimised for mathematical properties, | ||
### `crut.update(id, props, cb)` | ||
@@ -234,16 +271,20 @@ | ||
- if `cb` is not passed, a Promise is returned instead. | ||
- if there is a merge conflict that needs resolving and your update does not resolve it you will get an Error with | ||
- `err.message` - describes in human sentence the fields which has conflicts | ||
- `err.fields` - an Array of fields which had conflicts | ||
- by default, updates are accepted from everyone. To change this, specifiy behaviour in `isValidUpdate` e.g. | ||
```js | ||
spec.isValidUpdate = (context, msg) => { | ||
const { accT, graph } = context | ||
```js | ||
spec.isValidUpdate = (context, msg) => { | ||
const { accT, graph } = context | ||
if (!graph) return true | ||
// crut.read has graph, but crut.update doesn't yet | ||
// this means updates from others can be published but will be ignored | ||
if (!graph) return true | ||
// crut.read has graph, but crut.update doesn't yet | ||
// this means updates from others can be published but will be ignored | ||
return graph.rootNodes.some(root => { | ||
return root.value.author === msg.value.author | ||
}) | ||
} | ||
``` | ||
return graph.rootNodes.some(root => { | ||
return root.value.author === msg.value.author | ||
}) | ||
} | ||
``` | ||
@@ -265,3 +306,3 @@ | ||
--- | ||
--- | ||
@@ -275,3 +316,3 @@ ### Using spec.arbitraryRoot with Private Groups | ||
The methods you can do this with are: | ||
The methods you can do this with are: | ||
- `crut.updateGroup(groupId, props, cb)` | ||
@@ -287,6 +328,3 @@ - `crut.readGroup(groupId, cb)` | ||
- `crut.update` does not currently publish merges | ||
- currently extends the tip with most recent activity | ||
- want to change this in the future but @tangle/reduce will needs more work | ||
- Show user error message caused by failed merge | ||
@@ -9,2 +9,3 @@ const test = require('tape') | ||
const fixRecps = require('./db2-compat') | ||
const keys = require('ssb-keys') | ||
@@ -125,2 +126,6 @@ test('create (minimal)', t => { | ||
}, | ||
// { | ||
// allProps: ['dogo'], | ||
// expectedErr: 'allProps must be an Object' | ||
// }, | ||
{ | ||
@@ -249,1 +254,24 @@ allProps: { dogo: 'pup' }, | ||
}) | ||
test('create, opts = { feedId, publish }', t => { | ||
if (!process.env.DB2) return t.end() | ||
const otherKeys = keys.generate() | ||
const server = Server({ recpsGuard: true }) | ||
const spec = Spec() | ||
const crut = new CRUT(server, spec, { | ||
feedId: otherKeys.id, | ||
publish: (content, cb) => server.db.publishAs(otherKeys, content, cb) | ||
}) | ||
crut.create({ parent: 'mix', recps: [server.id] }, (err, msgKey) => { | ||
t.error(err, 'creates') | ||
server.get(msgKey, (_, value) => { | ||
t.equal(value.author, otherKeys.id, 'create with proper author') | ||
server.close() | ||
t.end() | ||
}) | ||
}) | ||
}) |
@@ -7,2 +7,3 @@ const test = require('tape') | ||
const server = { backlinks: true } // mock server! | ||
let spec | ||
@@ -17,2 +18,3 @@ test('new Crut', t => { | ||
/* protected tangle */ | ||
t.throws( | ||
@@ -23,2 +25,7 @@ () => new CRUT(server, Spec({ tangle: 'group' })), | ||
) | ||
t.throws( | ||
() => new CRUT(server, Spec({ type: 'group/something', tangle: undefined })), | ||
/Invalid spec/, | ||
'spec.type = "group/something" throws (when tangle not set)' | ||
) | ||
@@ -31,3 +38,3 @@ /* protected props */ | ||
() => new CRUT(server, spec), | ||
/Invalid spec: spec.props.* reserved/, | ||
new RegExp(`Invalid spec: spec.props.${prop} .*reserved`), | ||
`spec with props.${prop} throws` | ||
@@ -37,2 +44,3 @@ ) | ||
/* protected props */ | ||
PROTECTED_STATIC_PROPS.forEach(prop => { | ||
@@ -48,3 +56,12 @@ const spec = Spec() | ||
spec = Spec() | ||
const propName = Object.keys(spec.props)[0] | ||
spec.staticProps[propName] = { type: 'string' } | ||
t.throws( | ||
() => new CRUT(server, spec), | ||
new RegExp(`props and staticProps may not have the same name \\[${propName}\\]`), | ||
`spec which has ${propName} in both props + staticProps throws` | ||
) | ||
t.throws( | ||
() => new CRUT({}, Spec()), // mock server missing backlinks | ||
@@ -51,0 +68,0 @@ /requires .* ssb-backlinks/, |
@@ -53,3 +53,6 @@ const test = require('tape') | ||
tombstone: null | ||
}] | ||
}], | ||
autoFollow: true, | ||
tombstone: null, | ||
conflictFields: [] | ||
}, | ||
@@ -92,3 +95,6 @@ 'public #read' | ||
tombstone: null | ||
}] | ||
}], | ||
autoFollow: true, | ||
tombstone: null, | ||
conflictFields: [] | ||
}, | ||
@@ -141,3 +147,6 @@ 'private #read' | ||
tombstone: null | ||
}] | ||
}], | ||
autoFollow: true, | ||
tombstone: null, | ||
conflictFields: [] | ||
}, | ||
@@ -144,0 +153,0 @@ 'private #readGroup' |
const test = require('tape') | ||
const { promisify } = require('util') | ||
const { replicate } = require('scuttle-testbot') | ||
@@ -6,2 +7,3 @@ const CRUT = require('../') | ||
const Spec = require('./spec.mock') | ||
const { expectedState } = require('./util') | ||
@@ -41,3 +43,12 @@ test('read', t => { | ||
if (err) throw err | ||
const states = [{ | ||
key: update.key, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
const expected = { | ||
@@ -50,12 +61,5 @@ key: profileId, | ||
recps: null, | ||
states: [{ | ||
key: update.key, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
} | ||
@@ -107,2 +111,13 @@ | ||
const profile = await crut.read(profileId) | ||
const states = [{ | ||
key: update.key, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
t.deepEqual( | ||
@@ -117,12 +132,5 @@ profile, | ||
recps: null, | ||
states: [{ | ||
key: update.key, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
}, | ||
@@ -140,3 +148,3 @@ 'reads current state' | ||
test('read (multiple states)', t => { | ||
test('read (multiple states - conflicting)', t => { | ||
const server = Server() | ||
@@ -183,2 +191,23 @@ const spec = Spec() | ||
const states = [ | ||
{ | ||
key: updateB.key, | ||
preferredName: 'Māui B', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
}, | ||
{ | ||
key: updateA.key, | ||
preferredName: 'Māui A', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
} | ||
] | ||
t.deepEqual( | ||
@@ -193,24 +222,7 @@ profile, | ||
recps: null, | ||
states: [ | ||
{ | ||
key: updateB.key, | ||
preferredName: 'Māui B', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
}, | ||
{ | ||
key: updateA.key, | ||
preferredName: 'Māui A', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
} | ||
] | ||
states, | ||
...expectedState(states), | ||
conflictFields: ['preferredName'] | ||
}, | ||
'reads current states (and orders by timestamp - latest first)' | ||
'reads current states (and orders by timestamp - latest first, cannot automatic merge)' | ||
) | ||
@@ -227,2 +239,74 @@ | ||
test('read (multiple states - auto-mergeable)', async t => { | ||
const server = Server() | ||
const spec = Spec() | ||
const crut = new CRUT(server, spec) | ||
const profileId = await crut.create({ | ||
parent: 'Taranga', | ||
preferredName: 'Māui', | ||
attendees: { | ||
add: [{ id: server.id, seq: 1 }] | ||
} | ||
}) | ||
const publish = promisify(server.publish) | ||
// NOTE these both branch off from the root (see tangle previous) | ||
const updateA = await publish({ | ||
type: spec.type, | ||
preferredName: { set: 'Māui A' }, | ||
tangles: { | ||
[spec.tangle]: { root: profileId, previous: [profileId] } | ||
} | ||
}) | ||
await new Promise(resolve => setTimeout(resolve, 10)) // ensure not published at same time! | ||
const updateB = await publish({ | ||
type: spec.type, | ||
legalName: { set: 'Māui B' }, | ||
tangles: { | ||
[spec.tangle]: { root: profileId, previous: [profileId] } | ||
} | ||
}) | ||
// Now we see what read returns | ||
const profile = await crut.read(profileId) | ||
const expected = { | ||
key: profileId, | ||
type: spec.type, | ||
originalAuthor: server.id, | ||
parent: 'Taranga', | ||
child: null, | ||
recps: null, | ||
states: [ | ||
{ | ||
key: updateB.key, | ||
preferredName: 'Māui', | ||
legalName: 'Māui B', | ||
attendees: { [server.id]: [{ start: 1, end: null }] }, | ||
tombstone: null | ||
}, | ||
{ | ||
key: updateA.key, | ||
preferredName: 'Māui A', | ||
legalName: null, | ||
attendees: { [server.id]: [{ start: 1, end: null }] }, | ||
tombstone: null | ||
} | ||
], | ||
preferredName: 'Māui A', | ||
legalName: 'Māui B', | ||
attendees: { [server.id]: [{ start: 1, end: null }] }, | ||
tombstone: null, | ||
conflictFields: [] | ||
} | ||
t.deepEqual( | ||
profile, | ||
expected, | ||
'Read automatically merges tips into state prop (if all tips are nonconflicting)' | ||
) | ||
server.close() | ||
t.end() | ||
}) | ||
test('read (isValidNextStep ignores a friends update)', t => { | ||
@@ -270,2 +354,12 @@ const me = Server() | ||
const states = [{ | ||
key: profileId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
const expected = { | ||
@@ -278,11 +372,5 @@ key: profileId, | ||
recps: null, | ||
states: [{ | ||
key: profileId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
} | ||
@@ -347,2 +435,13 @@ t.deepEqual(profile, expected, 'current state ignores invalid update') | ||
const states = [{ | ||
key: update.key, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
[Hine]: [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
const expected = { | ||
@@ -355,12 +454,5 @@ key: profileId, | ||
recps: null, | ||
states: [{ | ||
key: update.key, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
[Hine]: [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
} | ||
@@ -413,3 +505,12 @@ t.deepEqual(profile, expected, 'current state ignores invalid update') | ||
if (err) throw err | ||
const states = [{ | ||
key: update.key, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
t.deepEqual( | ||
@@ -424,12 +525,5 @@ profile, | ||
recps: [server.id], | ||
states: [{ | ||
key: update.key, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
}, | ||
@@ -479,3 +573,11 @@ 'reads current state' | ||
if (err) throw err | ||
const states = [{ | ||
key: profileId, | ||
preferredName: { author: Māui, value: 'Māui' }, | ||
legalName: 'Ben', | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
t.deepEqual( | ||
@@ -490,11 +592,5 @@ profile, | ||
recps: null, | ||
states: [{ | ||
key: profileId, | ||
preferredName: { author: Māui, value: 'Māui' }, | ||
legalName: 'Ben', | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
}, | ||
@@ -501,0 +597,0 @@ 'reads decorated info' |
@@ -17,2 +17,3 @@ # Tests | ||
- TODO | ||
5. merge | ||
- spans read and update scenarios for merging |
@@ -17,10 +17,3 @@ const overwrite = require('@tangle/overwrite')() | ||
tangle: 'profile', | ||
isValidNextStep ({ accT, graph }, m) { | ||
if (m.value.content.tangles.profile.root === null) return true | ||
return Object.keys(accT.attendees) | ||
.some(attendee => attendee === m.value.author) | ||
// NOTE this only checks if a given author was ever mentioned | ||
// in the attendees transformations, not if they were added / removed! | ||
}, | ||
isValidNextStep, | ||
hooks: { | ||
@@ -43,1 +36,17 @@ isRoot: [ | ||
} | ||
function isValidNextStep ({ tips, graph }, m) { | ||
isValidNextStep.error = null | ||
if (m.previous === null) return true | ||
const isValid = tips.every(tip => { | ||
return Object.keys(tip.T.attendees) | ||
.some(attendee => attendee === m.author) | ||
// NOTE this only checks if a given author was ever mentioned | ||
// in the attendees transformations, not if they were added / removed! | ||
}) | ||
if (isValid) return true | ||
else { | ||
isValidNextStep.error = new Error(`Invalid update - ${m.author} is not attendee, so can't update`) | ||
} | ||
} |
const test = require('tape') | ||
const CRUT = require('../') | ||
const Server = require('./test-bot') | ||
const Spec = require('./spec.mock') | ||
const { expectedState } = require('./util') | ||
@@ -35,2 +37,13 @@ test('tombstone', t => { | ||
const states = [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: {}, | ||
tombstone: { | ||
// author: server.id, | ||
date: update.content.tombstone.set.date, | ||
reason: 'woops' | ||
} | ||
}] | ||
const expected = { | ||
@@ -43,14 +56,5 @@ key: profileId, | ||
recps: null, | ||
// tombstone? | ||
states: [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: {}, | ||
tombstone: { | ||
// author: server.id, | ||
date: update.content.tombstone.set.date, | ||
reason: 'woops' | ||
} | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
} | ||
@@ -65,2 +69,9 @@ | ||
if (err) throw err | ||
const states = [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: {}, | ||
tombstone: null | ||
}] | ||
@@ -74,10 +85,5 @@ const expected = { | ||
recps: null, | ||
// tombstone? | ||
states: [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: {}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
} | ||
@@ -114,2 +120,13 @@ t.deepEqual(profile, expected, 'read tombstoned state') | ||
const states = [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: {}, | ||
tombstone: { | ||
// author: server.id, | ||
date: update.content.tombstone.set.date, | ||
reason: 'woops' | ||
} | ||
}] | ||
const expected = { | ||
@@ -122,14 +139,5 @@ key: profileId, | ||
recps: null, | ||
// tombstone? | ||
states: [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: {}, | ||
tombstone: { | ||
// author: server.id, | ||
date: update.content.tombstone.set.date, | ||
reason: 'woops' | ||
} | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
} | ||
@@ -136,0 +144,0 @@ |
const test = require('tape') | ||
const { replicate } = require('scuttle-testbot') | ||
const pull = require('pull-stream') | ||
const keys = require('ssb-keys') | ||
const CRUT = require('../') | ||
const Server = require('./test-bot') | ||
const Spec = require('./spec.mock') | ||
const { expectedState } = require('./util') | ||
@@ -39,2 +42,13 @@ test('update', t => { | ||
server.get(updateId, (_, value) => { | ||
const states = [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
const expected = { | ||
@@ -47,12 +61,5 @@ key: profileId, | ||
recps: null, | ||
states: [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
} | ||
@@ -98,2 +105,12 @@ | ||
server.get(updateId, (_, value) => { | ||
const states = [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
const expected = { | ||
@@ -106,12 +123,5 @@ key: profileId, | ||
recps: null, | ||
states: [{ | ||
key: updateId, | ||
preferredName: 'Māui', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: 41 }], | ||
'@Hine-nui-te-pō': [{ start: 5000, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
} | ||
@@ -163,95 +173,2 @@ t.deepEqual(profile, expected, 'read returns updated state') | ||
test('update (forked state)', t => { | ||
const server = Server() | ||
const spec = Spec() | ||
const crut = new CRUT(server, spec) | ||
// TODO set up forked state manually | ||
// call update and document current expected behaviour | ||
// V this is just currently a test copied from read | ||
const Māui = server.id | ||
crut.create( | ||
{ | ||
parent: 'Taranga', | ||
preferredName: 'Māui', | ||
attendees: { | ||
add: [{ id: Māui, seq: 33 }] | ||
} | ||
}, | ||
(err, profileId) => { | ||
t.error(err, 'crut.create a profile') | ||
const manualUpdateA = { | ||
type: spec.type, | ||
preferredName: { set: 'Māui A' }, | ||
tangles: { | ||
[spec.tangle]: { root: profileId, previous: [profileId] } | ||
} | ||
} | ||
const manualUpdateB = { | ||
type: spec.type, | ||
preferredName: { set: 'Māui B' }, | ||
tangles: { | ||
[spec.tangle]: { root: profileId, previous: [profileId] } | ||
} | ||
} | ||
// NOTE these both branch off from the root (see tangle previous) | ||
server.publish(manualUpdateA, (err, updateA) => { | ||
t.error(err, 'manually publish an update') | ||
server.publish(manualUpdateB, (err, updateB) => { | ||
t.error(err, 'manually publish a forked update') | ||
/* now see what crut.update does */ | ||
crut.update(profileId, { preferredName: 'Māui C' }, (err, updateId) => { | ||
t.error(err, 'use crut.update on forked state') | ||
crut.read(profileId, (err, profile) => { | ||
if (err) throw err | ||
const expected = { | ||
key: profileId, | ||
type: spec.type, | ||
originalAuthor: server.id, | ||
parent: 'Taranga', | ||
child: null, | ||
recps: null, | ||
states: [ | ||
{ | ||
key: updateId, | ||
preferredName: 'Māui C', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
}, | ||
{ | ||
key: updateA.key, | ||
preferredName: 'Māui A', | ||
legalName: null, | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }] | ||
}, | ||
tombstone: null | ||
} | ||
] | ||
} | ||
t.deepEqual( | ||
profile, | ||
expected, | ||
'reads current states (and orders by timestamp - latest first)' | ||
) | ||
server.close() | ||
t.end() | ||
}) | ||
}) | ||
}) | ||
}) | ||
} | ||
) | ||
}) | ||
test('update (ssb-recps-guard)', t => { | ||
@@ -356,2 +273,13 @@ if (process.env.DB2) return t.end() | ||
const states = [{ | ||
key: updateId, | ||
preferredName: { author: server2.id, value: 'Māui !!!' }, | ||
legalName: 'Ben', | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }], | ||
[server2.id]: [{ start: 1, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
t.deepEqual( | ||
@@ -366,12 +294,5 @@ profile, | ||
recps: null, | ||
states: [{ | ||
key: updateId, | ||
preferredName: { author: server2.id, value: 'Māui !!!' }, | ||
legalName: 'Ben', | ||
attendees: { | ||
[Māui]: [{ start: 33, end: null }], | ||
[server2.id]: [{ start: 1, end: null }] | ||
}, | ||
tombstone: null | ||
}] | ||
states, | ||
...expectedState(states), | ||
conflictFields: [] | ||
}, | ||
@@ -396,1 +317,91 @@ 'reads decorated info' | ||
}) | ||
test('update (malformed attrs)', t => { | ||
const server = Server() | ||
const spec = Spec() | ||
const crut = new CRUT(server, spec) | ||
const tests = [ | ||
{ | ||
allProps: 'dogo', | ||
expectedErr: 'allProps must be an Object' | ||
}, | ||
// { | ||
// allProps: ['dogo'], | ||
// expectedErr: 'allProps must be an Object' | ||
// }, | ||
{ | ||
allProps: { dogo: 'pup' }, | ||
expectedErr: 'unallowed inputs: dogo' | ||
}, | ||
{ | ||
allProps: null, | ||
expectedErr: 'allProps must be an Object' | ||
}, | ||
{ | ||
allProps: undefined, | ||
expectedErr: 'allProps must be an Object' | ||
}, | ||
{ | ||
allProps: 0, | ||
expectedErr: 'allProps must be an Object' | ||
} | ||
] | ||
crut.create({ parent: 'dave' }, (err, recordId) => { | ||
if (err) throw err | ||
pull( | ||
pull.values(tests), | ||
pull.asyncMap(({ allProps, expectedErr }, cb) => { | ||
crut.create(allProps, (err) => { | ||
if (!err) { | ||
return cb(new Error(`Expected to see error '${expectedErr}'`)) | ||
} | ||
t.match(err.message, new RegExp(expectedErr, 'g'), expectedErr) | ||
cb(null, null) | ||
}) | ||
}), | ||
pull.collect((err) => { | ||
if (err) throw err | ||
server.close() | ||
t.end() | ||
}) | ||
) | ||
}) | ||
}) | ||
test('update, opts = { feedId, publish }', t => { | ||
if (!process.env.DB2) return t.end() | ||
const otherKeys = keys.generate() | ||
const server = Server() | ||
const spec = Spec() | ||
const crut = new CRUT(server, spec, { | ||
feedId: otherKeys.id, | ||
publish: (content, cb) => server.db.publishAs(otherKeys, content, cb) | ||
}) | ||
const input = { | ||
parent: 'mix', | ||
attendees: { | ||
add: [{ id: otherKeys.id, seq: 33 }] | ||
} | ||
} | ||
crut.create(input, (err, msgKey) => { | ||
t.error(err, 'creates') | ||
crut.update(msgKey, { preferredName: 'arj' }, (err, updateId) => { | ||
t.error(err, 'publish an update with crut.update') | ||
server.get(updateId, (_, value) => { | ||
t.equal(value.author, otherKeys.id, 'update with proper author') | ||
server.close() | ||
t.end() | ||
}) | ||
}) | ||
}) | ||
}) |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
104994
7
27
2979
318
14
5
+ Added@tangle/graph@3.2.0(transitive)
+ Added@tangle/reduce@5.0.5(transitive)
+ Added@tangle/strategy@4.1.2(transitive)
- Removed@tangle/overwrite@^2.0.1
- Removed@tangle/graph@2.1.0(transitive)
- Removed@tangle/overwrite@2.1.0(transitive)
- Removed@tangle/reduce@3.1.0(transitive)
- Removed@tangle/strategy@2.0.1(transitive)
Updated@tangle/reduce@^5.0.0
Updated@tangle/strategy@^4.1.0