Awesome
hyperbeedeebee
A MongoDB-like database built on top of Hyperbee with support for indexing
Based on this design
Usage
npm i --save hyperbeedeebee
const Hyperbee = require('hyperbee')
// This module handles networking and storage of hypercores for you
const SDK = require('hyper-sdk')
const {DB} = require('hyperbeedeebee')
const {Hypercore} = await SDK()
// Initialize a hypercore for loading data
const core = new Hypercore('example')
// Initialize the Hyperbee you want to use for storing data and indexes
const bee = new Hyperbee(core)
// Create a new DB
const db = new DB(bee)
// Open up a collection of documents and insert a new document
const doc = await db.collection('example').insert({
hello: 'World!'
})
// doc._id gets set to an ObjectId if you don't specify it
console.log(doc)
// Iterate through data as it's loaded (streaming)
// Usually faster and more memory / CPU efficient
for await (let doc of db.collection('example').find({
clout: {
$gt: 9000
},
})) {
console.log(doc)
}
// Create an index for properties in documents
// This drastically speeds up queries and is necessary for sorting by fields
await db.collection('example').createIndex('createdAt')
// Get all results in an array
// Can skip some results and limit total for pagination
const killbots = await db.collection('example')
.find({type: 'killbot'})
.sort('createdAt', -1)
.skip(30)
.limit(100)
// Get a single document that matches the query
const eggbert = await db.collection('example').findOne({name: 'Eggbert'})
Data Types
HyperbeeDeeBee uses MongoDB's BSON data types for encoding data.
You can import the bson
library bundled with HyperbeeDeeBee using the following code:
const { BSON } = require('hyperbeedeebee')
From there you can access any of the following data types:
Binary,
Code,
DBRef,
Decimal128,
Double,
Int32,
Long,
UUID,
Map,
MaxKey,
MinKey,
ObjectId,
BSONRegExp,
BSONSymbol,
Timestamp
Important Differences From MongoDB
- There is a single writer for a hyperbee and multiple readers
- The indexing means that readers only need to download small subsets of the full dataset (if you index intelligently)
- No way to do "projections" so keep in mind you're always downloading the full document to disk
- Subset of
find()
API is implemented, no Map Reduce API, no$or
/$and
since it's difficult to optimize - You can only sort by indexed fields, otherwise there's no difference from loading all the data and sorting in memory
- Fully open source under AGPL-3.0 and with mostly MIT dependencies.
Indexing considerations:
Indexes are super important to make your applications snappy and to reduce the overall CPU/Bandwidth/Storage usage of queries.
- If you do a search by fields that aren't indexed, you'll end up downloading the full collection (this is potentially really slow)
- The order of fields in the index matters, they're used to create an ordered key based on the values
- If you want to sort by a field, make sure it's the first field in an index
- You can have indexed fields before the sorted field if they are only used for $eq operations, this is due to the database's ability to turn them into a prefix to speed up the search.
- If an index cannot be found to satisfy a
sort
the query will fail. - If you're using
$gt/$lt/$gte/$lte
in your query, they will perform best if the same considerations as the sort are applied. - If the fields in the index can be used to rule out a document as matching, then you can avoid loading more documents and doing fewer overall comparisons on data.
- If your field is a unicode string which has
0x00
bytes in it, then the sorting might break due to the way BSON serializes unicode strings. Proceed with caution!
API
const db = new DB(bee)
Initialize a new DB
instance using a hyperbee.
Note that it's up to you to figure out how to replicate the hyperbee (for added flexibility).
You may want to look into using hyper-sdk since it works out of the box in both Node.js and web browsers.
const collection = db.collection(name)
Get a reference to a Collection
of documents within this hyperbee.
This is where you will store documents as well as perform queries on them.
await db.close()
Close the hyperbee
and clean up any file descriptors it opened.
const collection = new Collection(name, bee)
Manually creates a Collection from a hyperbee and the collection name.
collection.name
The name of this collection in the hyperbeedeebee.
const doc = await collection.insert(doc)
Inserts a document into the collection.
Documents can contain any BSON data and most JS data types are automatically translated to their coresponding BSON types (e.g. Array and Date).
If the document doesn't have a _id
field, one will be generated for it.
The _id
field must be an ObjectID
.
If you want to update a document, do another insert
over the same _id
to overwrite the old document.
const name = await collection.createIndex(fields, opts={})
Create an index for a set of fields. This will go over all the documents in the collection and if they have the apropriate fields, will add them to the index. Indexing fields is important if you want to sort based on a query, or want to take advantage of sparsely querying the dataset to speed things up.
const index = await collection.getIndex(name)
const exists = await collection.indexExists(name)
const indexes = await collection.listIndexes()
const doc = await collection.findOne(query)
Search for a document that matches a given query.
If you specify a _id
you can find the document without needing to perform an actual search.
const cursor = collection.find(query)
You can also search through all documents for a particular query using a cursor. Cursors are like a "query builder" where you can specify additional properties like sorting and skipping.
You can get all matching documents in a cursor with await cursor
, or you can use for await(const doc of cursor)
to asynchronously iterate through the documents one at a time.
Using the AsyncIterator feature of cursors is preferred so that you can speed up your searches and avoid loading too much data into memory.
Note that the cursor will attempt to use any indexes that are in your query (or the sort) to speed up performance.
const docs = await cursor
You can treat the cursor as a promise to resolve the set of all documents within it. Note that every time you await the cursor, you're fetching the documents from the database since it isn't a "real" promise.
for await (const doc of cursor)
You can iterate through documents that match your query one at a time by using the cursor as an AsyncIterable. Note that every time you use it as an async iterable, you are performing a new search. This method is important if you're expecting a very large set of results or want to display things to users as data becomes available.
const cursor = cursor.skip(number)
You can skip a number of results for pagination (useful with cursor.limit
)
const cursor = cursor.limit(number)
You can limit how many documents you wish you fetch from the database.
const cursor = cursor.sort(field, direction=1)
You can sort the documents by a field if there is an index that uses that field available.
The direction
specifies whether the values should be incrementing (1
), or decrementing (-1
).
If you want to sort by a timestamp with the latest first, use -1
.
const cursor = cursor.hint(name)
Hint at which database index the search should use.
const count = await cursor.count()
Count the number of documents that match this query. Note that this operation does download the documents from peers.
const {nMatched,nModified, nUpserted} = await collection.update(query, update, {upsert=false,multi=false,hint=null} = {})
Update one or more documents in a collection that match a particular query
(same query format as .find()
).
You can specify that you want to update all documents that match using multi: true
.
You can have the DB insert a document if it doesn't exist by specifying upsert:true
.
You can specify a hint
for which DB to use when searching for documents.
The update
can either be a plain JavaScript object that maps which properties should be set, or it can be an Update
object with properties that are documented below.
Note that order is not guaranteed if you specify several update
operations that use the same key.
The update
can also be an Array of Update
objects in which case the operations will be applied in that order.
The return value contains fields for nMatched
(number of documents that got matched in the search),
nModified
(number of documents that got modified), and nUpserted
(number of documents that got upserted if upsert: true
was set in the options.
E.g.
const {nModified} = await collection.update({
birthday: today
}, {
$inc: {age: 1}
}, {
multi: true
})
const {nUpserted} = await collection.update({
some_impossible_search: Infinity
}, {
hello: 'World!'
}, {
upsert: true
})
query[field] query[field].$eq
Find fields that are equal to a specific value.
E.g.
const docs = await collection.find({
name: 'Bob'
})
// Equivalent to
const docs = await collection.find({
name: {
$eq: 'Bob'
}
})
query[field].$gt query[field].$lt query[field].$gte query[field].$lte
You can query by values that are greather than or less than a given value.
E.g.
const docs = await collection.find({
createdAt: {
$lte: new Date(),
$gt: new Date(2012, 01, 01)
}
}).sort('createdAt', -1)
query[field].$in
Check if a field is equal to one of a set of values in an array. If the field is an array, it checks that the array contains a subset of the query array.
E.g.
const docs = await collection.find({
tags: {
$in: ['cool', 'cats', 'spaghetti']
}
})
query[field].$all
Check if a field (which is an array) contains all the values within the query array.
E.g.
const docs = await collection.find({
permissions: {
$all: ['read', 'write', 'create']
}
})
query[field].$exists
Check if a field exists in a document.
Note that it's impossible to use indexes for $exists: false
at the moment.
const docs = await collection.find({
secret: {
$exists: false
}
})
update[field] = value
You can set a field in a document by specifying it.
Note that nested fields with .
are not yet supported, and fields with $
at the start may conflict with other query parameters.
Effectively an alias for update.$set[field]
// add the field `hello` to all documents in the collection
await collection.update({}, {
hello: 'world',
goodbye: 'space'
}, {multi:true})
update.$set[field] = value
You can set a field in the document to a specific value using $set
.
// add the field `hello` to all documents in the collection
await collection.update({}, {
$set: {
hello: 'world',
something: 'else'
}
}, {multi:true})
update.$unset[field] = ''
You can delete a field from a document using $unset
.
The value of the query can be anything.
await collection.update({}, {
$unset: {
honor: ''
}
}, {multi:true})
update.$rename[field] = newName
You can rename fields in a document using $rename
Effectively it deletes the existing field
and sets the newName
field to the value of the old field.
await collection.update({}, {
$rename: {
oldFieldName: 'newFieldName'
}
}, {multi:true})
update.$inc[field] = number
You can use $inc
to specify fields that should be incremented.
The number
is the amount to increment by.
You can set number
to a negative number to decrement fields.
If the field is not set in the document, the field will be set to number
.
await collection.update({}, {
$inc: {
points: 1000
}
}, {multi:true})
update.$mult[field] = number
You can use $mult
to specify fields that should be multiplied.
The number
is the amount to multiply by.
If the field is not set in the document, the field will be set to number
.
await collection.update({}, {
$mult: {
hp: 2,
mp: 0.5
}
}, {multi:true})
update.$push[field] = value
You can append to the end of an array using $push
// Add `adorable` to all objects with `tags` containing `cute`
await collection.update({tags: 'cute'}, {
$push: {
tags: 'adorable'
}
}, {multi:true})
update.$addToSet[field] = value
You can append a value to an array if that array doesn't already contain the value.
Useful for avoiding duplication.
If the field
is not in the document, it will be set to an array with the value
.
// Add `adorable` to all objects with `tags` containing `cute`
// Avoids adding it to thing that are already adorable
await collection.update({tags: 'cute'}, {
$addToSet: {
tags: 'adorable'
}
}, {multi:true})
You can use $each
in the value
to add a set of values.
await collection.update({tags: 'cute'}, {
$addToSet: {
tags: {
$each: ['adorable', 'fluffy']
}
}
}, {multi:true})
update.$pop[field] = direction
You can remove an element from the end or start of an array using $pop
.
The direction
must be either 1
or -1
where 1
removes from the end, and -1
removes from the start.
await collection.update({}, {
$pop: {
fromTheBack: 1,
fromTheFront: -1
}
}, {multi: true})
update.$pull[field] = query
You can remove all elements that match a given query using $pull
.
The query
should match the queries used for field fields in .find()
.
// Find everyone that is cool, and remove `uncool` and `boring` from their `qualities` array.
await collection.update({
isCool: true
}, {
$pull: {
qualities: {
$in: ['uncool', 'boring']
}
}
})
TODO:
- Sketch up API
- Insert (with BSON encoding)
- Find all docs
- Find by
_id
- Find by field eq (no index)
- Find by array field includes
- Find by number field
$gt
/$gte
/$lt
/$lte
- Numbers
- Dates
- Find using
$in
operator - Find using
$all
operator - Find using
$exists
operator - Index fields
- Sort by index (with find)
- Indexed find by field
$eq
- Flatten array for indexes
- Get field values from index key without getting the doc
- Find on fields that aren't indexed
- Indexed find for
$exists
- Indexed find by number field
- Indexed find for
$in
- Indexed find for
$all
- Hint API (specify index to use)
- Delete documents (clean up indexed values for them)
- Test if iterators clean up properly
- More efficient support for
$gt
/$gte
/$lt
/$lte
indexes - More efficient support for
$all
indexes - More efficient support for
$in
indexes - Detect when data isn't available from peers and emit an error of some sort instead of waiting indefinately.