tim
Version:
Tradle's P2P OTR-based chat with plaintext and structured messages, and the ability to externalize messages onto a blockchain (currently bitcoin's)
432 lines (377 loc) • 11.2 kB
JavaScript
var debug = require('debug')('msgDB')
var typeforce = require('typeforce')
// var lexint = require('lexicographic-integer')
var collect = require('stream-collector')
var levelup = require('levelup')
var Q = require('q')
var pump = require('pump')
// var levelQuery = require('level-queryengine')
// var jsonQueryEngine = require('jsonquery-engine')
var safe = require('safecb')
var constants = require('@tradle/constants')
// var TxData = require('tradle-tx-data').TxData
var levelErrs = require('level-errors')
var LiveStream = require('level-live-stream')
var CUR_HASH = constants.CUR_HASH
var ROOT_HASH = constants.ROOT_HASH
var TYPE = constants.TYPE
var TYPES = constants.TYPES
var lb = require('logbase')
var Entry = lb.Entry
var LogBase = lb.Simple
var EventType = require('./eventType')
var rebuf = require('./rebufEncoding')
var DEBUG = require('./debug')
var utils = require('./utils')
var Errors = require('./errors')
var now = utils.now
var ENTRY_TIMEOUT = DEBUG ? false : 5000
var MAIN_PREFIX = 'm!'
var TIMESTAMP_PREFIX = 't'
var CHAIN_TIMESTAMP_PREFIX = TIMESTAMP_PREFIX + 'c!'
var SEND_TIMESTAMP_PREFIX = TIMESTAMP_PREFIX + 's!'
module.exports = function createMsgDB (path, options) {
typeforce('String', path)
typeforce({
leveldown: 'Function',
log: 'Log'
}, options)
var db = levelup(path, {
db: options.leveldown,
valueEncoding: rebuf
})
db = LogBase({
db: db,
log: options.log,
process: processEntry,
timeout: typeof options.timeout === 'undefined' ? ENTRY_TIMEOUT : options.timeout,
autostart: options.autostart
})
var topics = [
EventType.misc.forget,
EventType.msg.new,
EventType.msg.edit,
EventType.misc.addIdentity,
EventType.msg.receivedValid,
EventType.msg.receivedInvalid,
EventType.msg.sendSuccess,
EventType.msg.sendError,
EventType.chain.writeSuccess,
EventType.chain.writeError,
EventType.chain.readSuccess,
EventType.chain.readError
]
// db = levelQuery(db)
// db.query.use(jsonQueryEngine())
// db.ensureIndex(CUR_HASH)
// db.ensureIndex(ROOT_HASH)
LiveStream.install(db)
// db.byCurHash = function (curHash, cb) {
// collect(db.createReadStream({
// start: curHash,
// end: curHash + '\xff'
// }), cb)
// }
db.byRootHash = db.liveOnly(function (rootHash, cb) {
collect(db.rawReadStream({
keys: false,
start: MAIN_PREFIX + rootHash,
end: MAIN_PREFIX + rootHash + '\xff'
}), function (err, entries) {
if (err) return cb(err)
if (!entries || !entries.length) {
return cb(new levelErrs.NotFoundError())
}
return cb(null, entries)
})
})
db.byCurHash = db.liveOnly(function (curHash, all, cb) {
// TODO: add indices for searchable properties
// to make this more efficient
if (typeof all === 'function') {
cb = all
all = false
}
cb = safe(cb)
var keys = db.createKeyStream()
var found
keys.on('data', function (key) {
var parsed = utils.parseUID(key)
if (parsed[CUR_HASH] !== curHash) return
found = true
keys.destroy()
if (parsed.public || !all) {
return db.get(key, cb)
}
// get all entries for this curHash
// for all from/to
// TODO: remove assumption on key formation
start = parsed[ROOT_HASH] + '-' + parsed[CUR_HASH]
collect(db.createReadStream({
keys: false,
start: start,
end: start + '\xff'
}), function (err, list) {
if (err) {
cb(err)
} else if (!list || !list.length) {
errWithNotFound(cb)
} else {
cb(null, list)
}
})
})
keys.on('end', function () {
if (!found) errWithNotFound(cb)
})
})
db.byRootHashAndCurHash = db.liveOnly(function (rootHash, curHash, cb) {
return db.byRootHash(rootHash, function (err, entries) {
if (err) return cb(err)
var found = entries.some(function (e) {
if (e[CUR_HASH] === curHash) {
cb(null, e)
}
})
if (!found) {
cb(null, new levelErrs.NotFoundError())
}
})
})
var createReadStream = db.createReadStream
db.createReadStream = function (opts) {
opts = opts || {}
if (!(opts.gt || opts.lt || opts.gte || opts.lte || opts.start || opts.end)) {
opts.gt = MAIN_PREFIX
opts.lt = MAIN_PREFIX + '\xff'
}
return createReadStream.call(db, opts)
}
db.byUID = function (key, cb) {
return db.get(MAIN_PREFIX + key, cb)
}
db.getToChainStream = function (opts) {
return db.liveStream(getToChainStreamOpts(opts))
}
db.getToSendStream = function (opts) {
return db.liveStream(getToSendStreamOpts(opts))
}
db.getConversation = function (otherGuyRootHash, raw, cb) {
if (typeof raw === 'function') {
cb = raw
raw = false
}
collect(pump(
raw ? db.rawReadStream({ keys: false }) : db.createValueStream(),
utils.filterStream(function (val) {
var from = val.from && val.from[ROOT_HASH]
var to = val.to && val.to[ROOT_HASH]
return val[TYPE] !== TYPES.IDENTITY &&
(from === otherGuyRootHash || to === otherGuyRootHash)
})
), cb)
}
db.setMaxListeners(0)
return db
function getToSendStreamOpts (opts) {
opts = opts || {}
opts.gt = SEND_TIMESTAMP_PREFIX
opts.lt = SEND_TIMESTAMP_PREFIX + '\xff'
return opts
}
function getToChainStreamOpts (opts) {
opts = opts || {}
opts.gt = CHAIN_TIMESTAMP_PREFIX
opts.lt = CHAIN_TIMESTAMP_PREFIX + '\xff'
return opts
}
function myDebug () {
var args = [].slice.call(arguments)
args.unshift(db.name)
return debug.apply(null, args)
}
function processEntry (entry, cb) {
var eType = entry.get('type')
if (topics.indexOf(eType) === -1) return cb()
entry = entry.clone()
var postEvent
switch (eType) {
case EventType.misc.forget:
// TODO: forget entry.get(ROOT_HASH)
forget(entry, cb)
return
case EventType.msg.new:
myDebug('new msg', entry.get(CUR_HASH))
break;
case EventType.msg.edit:
myDebug('edit msg', entry.get(CUR_HASH))
break;
case EventType.misc.addIdentity:
break
case EventType.msg.receivedValid:
postEvent = 'message'
// fall through
case EventType.msg.receivedInvalid:
myDebug('received msg', entry.get(CUR_HASH))
entry.set('dateReceived', now())
break
case EventType.msg.sendSuccess:
myDebug('sent msg', entry.get(CUR_HASH))
entry.set('dateSent', now())
postEvent = 'sent'
break
case EventType.msg.sendError:
case EventType.msg.giveUpSend:
myDebug('send error', entry.get(CUR_HASH))
break
case EventType.chain.writeSuccess:
myDebug('msg chained', entry.get(CUR_HASH), entry.get('txId'))
entry.set('dateChained', now())
postEvent = 'chained'
break
case EventType.chain.writeError:
myDebug('msg chained (error)', entry.get(CUR_HASH))
break
case EventType.chain.readSuccess:
myDebug('unchained', entry.get(CUR_HASH), entry.get('txId'), entry.get('dateReceived'))
entry.set('dateUnchained', now())
postEvent = 'unchained'
break
case EventType.chain.readError:
myDebug('msg unchained (error)', entry.get(CUR_HASH), entry.get('txId'))
try {
getKey(entry)
} catch (err) {
// not storing
return cb()
}
break
// TODO:
default:
myDebug('ignoring entry of type', eType)
return cb()
}
update(entry, function (batch) {
cb(batch)
if (postEvent && batch) {
process.nextTick(function () {
db.emit(postEvent, batch[0].value)
})
}
})
}
function update (entry, cb) {
var key
try {
key = getKey(entry)
} catch (err) {
myDebug(err.message)
return cb()
}
return db.get(key, function (err, state) {
if (!db.isOpen()) return cb()
if (err && !err.notFound) throw err // should never happen
var newEntry = state
? utils.updateEntry(state, entry)
: entry
var batch = [{
type: 'put',
key: getKey(newEntry),
value: newEntry.toJSON()
}]
// if (!utils.getEntryProp(newEntry, constants.TYPE)) {
// throw new Error(utils.getEntryProp(newEntry, 'type'))
// }
addTimestampOps(newEntry, entry, batch)
cb(batch)
})
}
function forget (entry, cb) {
var otherGuyRootHash = entry.get('who')
var togo = 3
var batch = []
collect(db.rawReadStream(getToChainStreamOpts()), onCollected)
collect(db.rawReadStream(getToSendStreamOpts()), onCollected)
db.getConversation(otherGuyRootHash, true, function (err, results) {
if (err) throw err
batch.push.apply(batch, results.map(function (entry) {
return {
key: getKey(entry),
type: 'del'
}
}))
finish()
})
function onCollected (err, results) {
if (err) return finish()
results.forEach(function (data) {
if (data.value.indexOf(otherGuyRootHash) !== -1) {
batch.push({
key: data.key,
type: 'del',
value: data.value
})
}
})
finish()
}
function finish () {
if (--togo === 0) {
cb(batch)
db.emit('forgot', otherGuyRootHash)
}
}
}
}
function getKey (entry) {
return MAIN_PREFIX + utils.getUID(entry)
}
function addTimestampOps (state, update, batch) {
var entryKey = getKey(state)
var eType = utils.getEntryProp(update, 'type')
var isNewOrEdit = eType === EventType.msg.new || eType === EventType.msg.edit
var giveUpSend = eType === EventType.msg.giveUpSend
if (utils.getEntryProp(state, 'chain')) {
if (isNewOrEdit) {
batch.push({
type: 'put',
key: getChainTimestampKey(state),
value: entryKey
})
} else if (utils.getEntryProp(state, 'dateChained') ||
utils.getErrors(state, 'chain') >= Errors.MAX_CHAIN) {
// remove from queue
batch.push({
type: 'del',
key: getChainTimestampKey(state),
value: entryKey
})
}
}
if (giveUpSend || utils.getEntryProp(state, 'deliver')) {
if (isNewOrEdit) {
batch.push({
type: 'put',
key: getSendTimestampKey(state),
value: entryKey
})
} else if (giveUpSend
|| utils.getEntryProp(state, 'dateSent')
|| utils.getErrors(state, 'send') >= Errors.MAX_SEND) {
batch.push({
type: 'del',
key: getSendTimestampKey(state),
value: entryKey
})
}
}
}
function getChainTimestampKey (entry) {
return CHAIN_TIMESTAMP_PREFIX + utils.getEntryProp(entry, 'timestamp')
}
function getSendTimestampKey (entry) {
return SEND_TIMESTAMP_PREFIX + utils.getEntryProp(entry, 'timestamp')
}
function errWithNotFound (cb) {
return cb(new levelErrs.NotFoundError())
}