MongoDB

The MongoBundle offers integration with MongoDB database by allowing you to hook into events, giving ability to work with model classes and add behaviors (such as timestampable, blameable). As well it is integrated with @kaviar/nova package which allows extremely rapid queries for relational data.

npm install --save @kaviar/mongo-bundle @kaviar/nova

Basic Setup#

import { MongoBundle } from "@kaviar/mongo-bundle";
new MongoBundle({
uri: "mongodb://localhost:27017/test",
// Optional if you have other options in mind
// https://mongodb.github.io/node-mongodb-native/3.6/api/MongoClient.html#.connect
options: MONGO_CONNECTION_OPTIONS,
});

Collections#

A collection is in fact a service. Thus making it accessible via the container. We define our collections as extensions of Collection in which we can customise things such as: collectionName, indexes, links, reducers, behaviors.

import { Collection } from "@kaviar/mongo-bundle";
// We can optionally create a type and have full typesafety
type User = {
firstName: string;
lastName: string;
};
class UsersCollection extends Collection<User> {
static collectionName = "users";
static indexes = [
{
key: { firstName: 1 },
// Other options can be found here in IndexSignature:
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0543eca60008efb636775a21aec7b7f5e798682a/types/mongodb/index.d.ts#L3408
},
];
}

As with everything in Kaviar's world, you get the instance via the container, for that you'd have to work within your application bundle.

const usersCollection = container.get(UsersCollection);
// You have access to the classic MongoDB Node Collection
usersCollection.collection;

You have access to directly perform the more popular mutation operations:

  • insertOne
  • insertMany
  • updateOne
  • updateMany
  • deleteOne
  • deleteMany
  • aggregate
  • find
  • findOne
  • findOneAndUpdate
  • findOneAndDelete

Events#

What's nice about this is that you can listen to all events for the operations you do strictly on the collection. If you do usersCollection.collection.insertMany the events won't be dispatched, but if you do usersCollection.insertMany it will.

Available events that can be imported from the package:

  • BeforeInsertEvent
  • AfterInsertEvent
  • BeforeUpdateEvent
  • AfterUpdateEvent
  • BeforeRemoveEvent
  • AfterRemoveEvent

They are very explicit and typed with what they contain, a sample usage would be:

import { AfterInsertEvent } from "@kaviar/mongo-bundle";
eventManager.addListener(AfterInsertEvent, async (e) => {
if (e.collection instanceof PostsCollection) {
// Do something with the newly inserted Post
const postBody = e.data.document;
const postId = e.data.result.insertedId;
}
});
// or simply do it on postsCollection.localEventManager

Events should be attached in the prepare() phase of your bundle. The common strategy is by warming up a Listener, as described in the core.

Events also receive a context variable. Another difference from classic MongoDB node collection operations is that we allow a context variable inside it that can be anything. That variable reaches the event listeners. It will be useful if we want to pass things such as an userId if we want some blameable behavior. You will understand more in the Behaviors section.

If you want to perform certain actions for elements once they have been updated or removed (events: AfterUpdateEvent and AfterRemoveEvent) the solution is to get the filter and extract the _id from there.

Integration with Nova#

Basics#

For fetching we use Nova. And the concept is simple:

// Fetch it from the container
const usersCollection = container.get(UsersCollection);
usersCollection.query({
$: {
filters: {
_id: someUserId
}
}
// Specify the fields you need
firstName: 1,
lastName: 1,
});
// use .queryOne() if you are expecting a single result based on filters

To integrate with Nova, you can do it via the following static variables

class UsersCollection extends Collection {
static collectionName = "users";
}
class PostsCollection extends Collection {
static collectionName = "posts";
static links = {
user: {
collection: () => UsersCollection,
field: "userId",
},
};
// Nova reducers
// Note: reducers include "container" in the context property of their params.
static reducers = {};
// Nova expanders
static expanders = {};
}

Now you can query freely:

postsCollection.query({
title: 1,
user: {
firstName: 1,
lastName: 1,
},
});

GraphQL#

If you are looking to write a Nova Query in your GraphQL resolvers you can do:

const resolvers = {
Query: {
posts(_, args, ctx, ast) {
const postsCollection = ctx.container.get(PostsCollection);
return postsCollection.queryGraphQL(ast, {
// These are the AstToQueryOptions presented in the link above as: Nova Query
filters: {
isApproved: true,
},
});
},
},
};

If you want the query to return a single element, a short-hand function is postsCollection.queryOneGraphQL().

Behaviors#

The nature of the behavior is simple, it is a function that receives the collection object when the collection initialises. And you can listen to events on the collection and make it behave.

import { Behaviors, Collection } from "@kaviar/nova";
class UsersCollection extends Collection {
static behaviors = [
Behaviors.Timestampable({
// optional config
fields: {
// mention the actual field names to be saved
createdAt: "createdAt",
updatedAt: "updatedAt",
},
}),
Behaviors.Blameable({
// optional config
fields: {
createdBy: "createdBy",
updatedBy: "updatedBy",
},
userIdFieldFromContext: "userId",
}),
Behaviors.Softdeletable({
// optional config
fields: {
isDeleted: "isDeleted",
deletedAt: "deletedAt",
deletedBy: "deletedBy",
},
}),
// The validate behavior works with transactions and ensures the full document is correct
Behaviors.Validate({
// optional config
model: User,
options: {}, // validate options
}),
];
}

Now, you may have behaviors that require you to provide a context to the operations. Not doing so, they will be allowed to throw exceptions and block your execution. For example if you have blameable behavior, and you do not have a context with userId being either null either a value, an exception will be thrown.

await usersCollection.insertOne(
{
firstName: "John",
lastName: "Smithsonian",
},
{
context: {
userId: "XXX", // or null, but not undefined.
},
}
);

If you need to access the container, for example, you want to log the events into an external service you can access the container via collection.container.

Creating Behaviors#

The behavior is a function that take in the collection's instance and performs manipulation on it, b y either hooking to events or overriding/extending its methods.

Each collection has it's own event manager in which it dispatches events. When adding behaviors it would be great to use the localEventManager.

const DeadSimpleTimestampable = () => {
return (collection: Collection) => {
collection.localEventManager.addListener(
// These are the standard events that also get into the global event manager
BeforeInsertEvent,
(e: BeforeInsertEvent) => {
const document = e.data.document;
const now = new Date();
Object.assign(document, {
createdAt: now,
});
}
);
};
};
class MyCollection extends Collection {
static behaviors = [DeadSimpleTimestampable()];
}

Models#

If we need to have logicfull models then it's easy. We are leveraging the power of class-transformer to do exactly that.

import { ObjectID } from "mongodb";
import { Collection } from "@kaviar/mongo-bundle";
class User {
_id: ObjectID;
firstName: string;
lastName: string;
get fullName() {
return this.firstName + " " + this.lastName;
}
}
// You can also use it as a type
class UsersCollection extends Collection<User> {
static collectionName = "users";
static model = User;
}
const user = usersCollection.queryOne({
firstName: 1,
lastName: 1,
});
user instanceof User;
user.fullName; // will automatically map it to User model class

Now, if you want to query only for fullName, because that's what you care about, you'll have to use expanders. Expanders are a way to say "I want to compute this value, not Nova, so when I request this field, I need you to actually fetch me these other fields"

class UsersCollection extends Collection<User> {
static collectionName = "users";
static model = User;
static expanders = {
fullName: {
firstName: 1,
lastName: 1,
},
};
}
const user = usersCollection.queryOne({
fullName: 1,
});
user instanceof User;
user.firstName; // will exist
user.lastName; // will exist
user.fullName; // will automatically map it

However, you can also leverage Nova to do this computing for you like this:

class User {
_id: ObjectID;
firstName: string;
lastName: string;
fullName: string; // no more computing
}
class UsersCollection extends Collection<User> {
static collectionName = "users";
static model = User;
static reducers = {
fullName: {
dependency: {
firstName: 1,
lastName: 1,
},
reduce({ firstName, lastName }) {
return this.firstName + " " + this.lastName;
},
},
};
}
const user = usersCollection.queryOne({
fullName: 1,
});
user instanceof User;
user.firstName; // will NOT exist
user.lastName; // will NOT exist
user.fullName; // will be what you requested

If you want to have nested models that reference other collections, it's easy:

import { Type } from "@kaviar/mongo-bundle";
class User {
_id: ObjectID;
name: string;
}
class Comment {
_id: ObjectID;
title: string;
}

Now doing the query:

const user = usersCollection.queryOne({
name: 1,
comments: {
title: 1,
},
});
// user.comments will be an Array of Comment objects

Transactions#

If you want to ensure that all your updates are run and if an exception occurs you would like to rollback. For example, you have event listeners that you never want to fail, or you do multiple operations in which you must ensure that all run and if something bad happens you can revert.

const dbService = container.get(DatabaseService);
await dbService.transact((session) => {
await usersCollection.insertOne(document, { session });
await postsCollection.updateOne(filter, modifier, { session });
});

The beautiful thing is that any kind of exception will result in transaction revertion. If you want to have event listeners that don't fail transactions, you simply wrap them in such a way that their promises resolve.

Migrations#

Migrations allow you to version and easily add new changes to database while staying safe. Migrations are added in the prepare() phase of your bundles.

const migrationService = this.container.get(MigrationService);
migrationService.add({
version: 1,
name: "Do something",
async up(container) {
// Setup defaults for a certain collection
},
async down(container) {
// Revert the up() function, in case something goes wrong and you want to rollback
},
});

By default migrations are run by default to latest version right after MongoBundle gets initialised.

const migrationService = this.container.get(MigrationService);
new MongoBundle({
uri: "...",
// Take control and disable auto migration
automigrate: false,
});
// Control it from here
migrationService.migrateToLatest();
migrationService.migrateTo(version);

The way we handle migrations is that we store in migrations collection a status object containing:

export interface IMigrationStatus {
version: number; // The current version
locked: boolean;
lockedAt?: Date;
lastError?: {
fromVersion: number;
message: string;
};
}