What is Astronomy?

The Astronomy 2.x package introduces the Model Layer for Meteor collections. It can also be named the Object Document Mapping system (ODM) or for people coming from relational database environments the Object-Relational Mapping system (ORM).

Leaving terminology aside, Astronomy gives you a possibility to define a document’s schema that includes field definitions, helpers, methods, events, validators and many more. As a result, programming is much easier and the amount of code you have to write much smaller. But a picture is worth a thousand words, so let’s take a look at a simple example.

Example

When fetching documents from Mongo collections, you get plain JavaScript objects without any logic. You have to validate values of objects’ properties, check what fields have changed, save only modified fields, transform values coming from forms, in every place you are playing with a document; a lot of things to do. Wouldn’t it be great if you could define some simple rules and leave everything else to framework? It’s actually possible thanks to Astronomy. But first let’s take a look at how your code would look like without using Astronomy.

var post = Posts.findOne(id);
// Assign values manually instead doing it automatically.
post.createdAt = new Date();
post.userId = Meteor.userId();
// Manually convert values coming from the form.
post.title = tmpl.find('input[name=title]').value;
post.publishedAt = new Date(tmpl.find('input[name=publishedAt]').value);
// Every time implement custom validation logic.
if (post.title.length < 3) {
  // Implement an error messages system.
  throw new Error('The "title" field has to be at least 3 characters long');
} else {
  // Detect what fields have changed and update only those.
  // Access collection directly.
  Posts.update({
    _id: post._id
  }, {
    $set: {
      title: post.title,
      publishedAt: post.publishedAt,
      createdAt: post.updateAt
    }
  });
}

With Astronomy and defined schema your code would look like follows:

// Notice that we call the "findOne" method
// from the "Post" class not from the "Posts" collection.
var post = Post.findOne(id);
// Auto convert a string input value to a number.
post.title = tmpl.find('input[name=title]').value;
post.publishedAt = new Date(tmpl.find('input[name=publishedAt]').value);
// Check if all fields are valid and update document
// with only the fields that have changed.
post.save();

What approach is simpler? I think the choice is obvious.

For clarity, here is a sample schema that allows that. May seem to be a lot of code but have in mind that you write it only once.

import { Class } from 'meteor/jagi:astronomy';

const Posts = new Mongo.Collection('posts');
const Post = Class.create({
  name: 'Post',
  collection: Posts,
  fields: {
    title: {
      type: String,
      validators: [{
        type: 'minLength',
        param: 3
      }]
    },
    userId: String,
    publishedAt: Date
  },
  behaviors: {
    timestamp: {}
  }
});

Why should I use Astronomy?

There are many other packages that implement some of the functionalities present in Astronomy. I will try to point out here the main benefits of using Astronomy over other solutions, besides having many features that are listed in the Features section.

  • Astronomy is highly modularized. This was one of the main principles when creating it. As a result, anyone can easily hook into almost every process that happens in Astronomy. Developers can create their own modules, behaviors and validators.
  • It’s easy to learn and use. Astronomy does not reinvent the wheel. It takes the best from the tools you are already familiar with, not only from the JavaScript world, but also from other languages.
  • When using Astronomy, you can easily replace three to five packages that you already use with a single one that follows the same pattern across all its modules. The main principle is simplicity.
  • It follows quite different principles to do the job than other packages do. As a result, the amount of code you have to write to setup your classes and create application logic is significantly lower.
  • There are many developers and companies that are already using Astronomy in production.

Here are some comments from developers using Astronomy:

If this package were around when I created SimpleSchema, I would have used it instead of creating SimpleSchema.

Eric Dobbertin, author of SimpleSchema

I love your package, it’s really great […] as RoR developer, this package is really exciting!

rsignavong

Anyway, very happy to have moved to astronomy, really like it we will release an app using it soon, so I will let you know.

banglashi

I still don’t understand how this package is not getting more popular imho this package is better than simple-schema.

dstollie

Amazing work, beautifully designed package! Anyone give this package few seconds and take a look at sources? If people will write code in such clean and modular way, world would be better! Kudos Jagi!

Kuba Wyrobek

Amazing package indeed! Coming from a php MVC background, this package is a gift :). You rock!

roelvan

Thanks - this has the makings of an amazing tool…. I’m hoping to replace Mesosphere, collection-hooks, collection-helpers, and a bunch of custom code - all with a cleaner code base!

dovrosenberg

History

The idea for creating a package for Meteor that would introduce a Model Layer emerged after creating several simple Meteor applications. I noticed that I was constantly repeating the same parts of a code to manage documents’ storage and validation. It was frustrating in comparison to what I’ve been accustomed to in the Doctrine library for PHP that I had used for many years.

This is why I’ve decided to create a Meteor package that would take the best from the Doctrine library. The first version was released in 2013 and was named Verin Model. It worked well in my projects, so I made it available to the community through the Meteorite package installer. However, I haven’t been promoting it anywhere so the number of users was limited.

In the late 2014, I decided to give it one more try and implement a much better package. A package that would be modular, would have all the features from the previous package and a few more additions. In the meanwhile, many developers tried to fill a gap of lack of model layer in Meteor with their packages. Some of them (e.g. SimpleSchema) had features that I was looking for, but were too complex to use. Some packages just focused on single features (Collection Hooks, Collection Behaviours, Collection Helpers). I didn’t like the idea of using many packages that followed quite different rules. I just wanted one modular tool that would fit all my needs. That’s why I’ve created Astronomy.

Why the name “Astronomy”?

As almost everything that is Meteor-related has some space-related name, this one couldn’t be an exception. The model layer in the MVC pattern is a description of real objects. And, the science describing objects in space is called Astronomy. The choice was quick.

Features

Astronomy is highly modularized. Some basic features comes with the base jagi:astronomy package. Others have to be added as separate modules / packages. Here is a list of all the features with a short description.

Astronomy documents

Documents fetched from collections are not simple JavaScript objects but instances of classes you define using Astronomy.

Fields’ types

When defining a field list, you can specify their types like String, Number, Boolean, Date etc. Thanks to that it will automatically validate fields’ values.

Fields’ default values

On document initialization, you may want to set default values of some fields. In Astronomy it’s easy to do. You can also use functions to compute a default value.

Nested fields / classes

You can nest one or many class instances in a field.

Transient fields

You can define list of fields that won’t be stored in the database. You can use transient fields as normal fields.

Immutable fields

There are situations when you may want to mark a field as immutable so it won’t be possible to change field’s value once it has been saved into the database.

Document’s EJSON-ification

EJSON is an extension of JSON that supports more types. When sending documents from the client to the server over the DDP protocol (for instance in Meteor methods), they get stringified using the EJSON.strinigify() function. Astronomy classes are EJSON compatible, so you can send them over the DDP.

Methods

You can define helpers, so your document is not only a data storage but a “live” thing. A dog can bark dog.bark(); and a user can greet you user.sayHello();.

Events

Astronomy implements an events system that allows you to hook into many processes happening inside the package. For example, you can hook into the process of saving document. You may want to lowercase user’s nickname just before saving document. With Astronomy it’s a piece of cake. The events system also provides you a way to stop an event’s propagation and to prevent default behavior.

Getters and setters

Each Astronomy document has get() and set() methods. Thanks to them, you can get and set multiple fields at once and access nested fields in an easy way. For example you can call user.set('profile.email', 'example@mail.com') to set email address in the profile object.

Modified fields

Thanks to the doc.getModified() method you can access fields that had been modified from the last save.

Cloning document

It allows making copies of documents. You can automatically save cloned document or modify it before saving.

var copy = post.copy(); // Creates a new copy with an empty _id
copy.title = 'Foobar'; // Make changes before saving
copy.save(); // Save document

var anotherCopy = post.copy(true); // automatically saved

Reloading document

Sometimes after introducing some changes into your document, you may want to reload document to its new state from the database. For that task you can use the reload() method.

Indexes

This feature allows you to define indexes that will be created on given fields / columns to speed up the process of fetching data.

Inheritance

When there are classes with similar schemas, sometimes it’s simpler to create a base class and extend it by adding only the features that differ.

Multiple classes in the same collection

You can store instances of many classes in the same collection. You just have to tell Astronomy which field will store the name of the class to use when creating the instance.

Validation

When saving a document, Astronomy will validate its values agains rules you defined. If yo haven’t defined any rules it at least validate types of fields. You can also validate a document without saving it.

Validation order

You can define the order in which validation will take place.

Mongo queries

You can perform insert, update, upsert and remove mongo queries directly on a class with all the benefits of Astronomy, like default fields’ values or validation User.update(id, {$set: {firstName: 'John'}});.

Behaviors

Behavior is a nice way of reusing your code in more than one class. If you have similar features in two or more classes, you should consider creating a behavior for that feature. An example of a behavior is the Timestamp behavior which automatically sets createdAt and updateAt fields with the current date on every document save or update.

Timestamp behavior

The Timestamp Behavior adds two fields that store information about a document’s creation and update dates. Those fields will be automatically filled with the correct date. To use this behavior you have to add it to your project meteor add jagi:astronomy-timestamp-behavior.

Slug behavior

The Slug Behavior adds a slug field for storing a URL friendly value of a chosen field. The text Tytuł artykułu will be converted to tytul-artykulu. The slug field can then be used in routing http://localhost:3000/post/tytul-artykulu. To use this behavior you have to add it to your project meteor add jagi:astronomy-slug-behavior.

Softremove behavior

The Softremove Behavior adds the softRemove() method to your class which prevents a document from being removed. Instead, it will flag the document as removed and you will be able to omit such documents on documents fetch. To use this behavior you have to add it to your project meteor add jagi:astronomy-softremove-behavior.

Getting started

First, you have to create a Meteor project:

meteor create myapp

Open the project’s directory and add the Astronomy package.

cd myapp
meteor add jagi:astronomy

You’re ready to go.

Creating the first class

We’ll start by showing the simplest possible class implementation.

import { Class } from 'meteor/jagi:astronomy';

const Posts = new Mongo.Collection('posts');

const Post = Class.create({
  name: 'Post',
  collection: Posts
});

As you can see, we’ve created the Mongo collection named Posts and the Post class. It’s good to keep this convention. The Posts (plural) collection is a container for documents of the Post (singular) class. We’ve provided two attributes: name and collection. The name attribute is obligatory and it’s just an internal name of the class. The collection attribute just tells our class in which collection instance our class should be stored.

Now, we can create an instance of the Post class.

var post = new Post();

Our class is very simple and right now. It doesn’t have any fields, so let’s change that.

import { Class } from 'meteor/jagi:astronomy';

const Post = Class.create({
  name: 'Post',
  collection: Posts,
  fields: {
    title: String,
    publishedAt: Date
  }
});

Now, we have the class with the two fields: title and publishedAt. The type of the title field is String and the type of the publishedAt field is Date. Let’s create an instance of the class and fill it with some values.

var post = new Post();
post.title = 'Sample title';
post.publishedAt = new Date();

How do we save the document into database? Nothing simpler.

post.save();

Let’s assume that after a while, we want to change the post’s title.

// Notice that we call the "findOne" method from the "Post" class.
var post = Post.findOne({title: 'Sample title'});
post.title = 'New title';
post.save();

In the example above, we fetched previously saved document and modified its title. The save method ensures that only the title of the document will be updated in the database.

Adding validation

Let’s define some validation rules in our class.

import { Class } from 'meteor/jagi:astronomy';

const Post = Class.create({
  name: 'Post',
  collection: Posts,
  fields: {
    title: {
      type: String,
      validators: [{
        type: 'minLength',
        param: 3
      }, {
        type: 'maxLength',
        param: 40
      }]
    },
    publishedAt: Date
  }
});

We’ve modified the definition of the title field. Now instead passing a field type, we pass an object. The object contains two properties: type and validators. The type property is just the type of the field. The validator property is a list of validators for the given field. We’ve defined only two validators: minLength and maxLength. New validation rules will be check during the save operation.

var post = new Post({
  /* ... */
});
// Validate length of the "title" field.
post.save();

What’s next?

This is a brief introduction that covered only a tiny portion of Astronomy’s features. If you want to read more about Astronomy please take a look at the other sections in this documentation.

Changelog

You can find a changelog here.

Examples

The best way to understand Astronomy is learning by an example, that’s why there is an example git repository that you can clone and run to see Astronomy in action. The example repository shows usage of Astronomy with FlowRouter. I encourage you to take a look at the code to see how integration with form templates is done.

The Meteor.users collection

If you want to provide a schema for the Meteor.users class then here is a minimal example of such definition. Of course, you could make it more detailed. However, Meteor takes care of checking data validity for the Meteor.users collection, so you don’t have to do it one more time.

import { Class } from 'meteor/jagi:astronomy';

const UserProfile = Class.create({
  name: 'UserProfile',
  fields: {
    nickname: String
    /* Any other fields you want to be published to the client */
  }
});

const User = Class.create({
  name: 'User',
  collection: Meteor.users,
  fields: {
    createdAt: Date,
    emails: {
      type: [Object],
      default: function() {
        return [];
      }
    },
    profile: {
      type: UserProfile,
      default: function() {
        return {};
      }
    }
  }
});

if (Meteor.isServer) {
  User.extend({
    fields: {
      services: Object
    }
  });
}

Upgrading to 2.0

There are several changes in API that were made in Astronomy 2.0. Let’s discuss each one.

ES2015 support

Astronomy 2.0 uses ES2015 syntax in every file, including modules loading system. So, you should get familiar with it before switching. You can import data from Astronomy package in the following way.

import { Class } from 'meteor/jagi:astronomy';

Here is a list of all objects that you can import:

  • Class - for creating classes
  • Module - for creating modules
  • Enum - for creating ENUMs
  • Type - for creating custom types
  • Event - for creating custom events
  • Behavior - for creating custom behaviors
  • Validator - for creating custom validators
  • Validators - validators list
  • Field - field class, handy for modules authors
  • ScalarField - field class, handy for modules authors
  • ObjectField - field class, handy for modules authors
  • ListField - field class, handy for modules authors

Creating class

V1

var User = Astro.Class({});

V2

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({});

export User;

Type definition

Now, instead using string as a type, we use type constructor function.

V1

fields: {
  firstName: 'string'
}

V2

fields: {
  firstName: String
}

Nested fields

The way how we define nested fields got simplified and now we use [] array operator to define fields storing multiple values.

V1

fields: {
  address: {
    type: 'object',
    nested: 'Address'
  },
  phones: {
    type: 'array',
    nested: 'Phone'
  }
}

V2

fields: {
  address: Address,
  phones: [Phones]
}

The “this” context in event

The this context in event handler is not a document. Use event.target to get a document. Use event.currentTarget to get nested document.

events: {
  beforeSave(e) {
    const doc = e.currentTarget;
  }
}

Fetching documents

Astronomy 2.0 does not transform documents coming from the collection. Now, you have to use class level find() and findOne() methods to retrieve transformed documents.

V1

var post = Posts.findOne();
var posts = Posts.find();

V2

var post = Post.findOne();
var posts = Post.find();

Document modification

In Astronomy 2.0 you don’t have to use set() method to modify fields’ values. Now you can modify document freely and Astronomy will detect changes automatically. Methods like push(), inc(), pop() have been removed.

V1

user.set('firstName', 'John');
user.push('phones', '+48 123 123 123');

V2

user.firstName = 'John';
// However, you can still write for nested fields.
user.set('address.city', 'San Francisco');
// You can push directly into the array.
user.phones.push('+48 123 123 123');

Validation

There is a lot of changes in how validators are defined and how to handle validation process. To learn more about that, please go to this section.

Saving

Now, you don’t have to write Meteor methods in client and server to save a document. You can just write doc.save() on the client and the corresponding doc.save() method will be called on the server.

Key concepts

Fields definition

In Astronomy we can define a class/schema and provide list of fields. Let’s examine how to do that.

Simple fields list

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: String,
    createdAt: Date,
    age: Number
  }
});

In the example above, we passed an object with the fields’ names as keys and the fields’ types as values. There are several predefined types. You can choose from:

  • String
  • Number
  • Boolean
  • Date
  • Object
  • Mongo.ObjectID

You can also create your own types.

ENUM type

One example of custom type is ENUM. You can read more about what ENUMs are here. You can easily create ENUM types in Astronomy even when JavaScript does not have native support for it. Let’s take a look at the basic example of creating ENUM type.

import { Enum } from 'meteor/jagi:astronomy';

const Status = Enum.create({
  name: 'Status',
  identifiers: ['OPENED', 'CLOSED', 'DONE', 'CANCELED']
});

Now you can access each identifier in the following way.

Status.OPENED; // 0

Each identifier will represent successive numbers starting from 0.

  • OPENED = 0
  • CLOSED = 1
  • DONE = 2
  • CANCELED = 3

You may also want to provide non successive values, then you can define ENUM in the following way.

import { Enum } from 'meteor/jagi:astronomy';

const Status = Enum.create({
  name: 'Status',
  identifiers: {
    OPENED: 1,
    CLOSED: 2,
    DONE: 4,
    CANCELED: 8
  }
});

If there is lack of value for some identifier, then it will receive successive number. Let’s take a look at the example.

import { Enum } from 'meteor/jagi:astronomy';

const Status = Enum.create({
  name: 'Status',
  identifiers: {
    OPENED: 5,
    CLOSED: null,
    DONE: 15,
    CANCELED: undefined
  }
});

And values for each identifier will be.

  • OPENED = 5
  • CLOSED = 6
  • DONE = 15
  • CANCELED = 16

Usage

Now let’s take a look at how to use our Status type in the class schema.

import { Class } from 'meteor/jagi:astronomy';

const Issue = Class.create({
  name: 'Issue',
  /* ... */
  fields: {
    status: {
      type: Status
    }
  }
});

As you can see it’s quite straightforward. You can use this type as any other type. Benefit of having such a type is not only easy way of accessing identifiers but also automatic validation. So, Astronomy will not allow storing in the status field any value that is not listed in the ENUM’s definition.

Getting identifier

In some cases you may want to retrieve identifier for a number value that is stored in a field. Let’s take a look how to convert number value to identifier.

import { Enum } from 'meteor/jagi:astronomy';

const Status = Enum.create({
  name: 'Status',
  identifiers: ['OPENED', 'CLOSED', 'DONE', 'CANCELED']
});

var statusNumber = 0;

Status.getIdentifier(statusNumber); // "OPENED"

As you can see, it will return the "OPENED" string. You can also get all identifiers.

Status.getIdentifiers(); // ["OPENED", "CLOSED", "DONE", "CANCELED"]

Non number values

You can also define non number ENUMs, however it’s not recommended.

import { Enum } from 'meteor/jagi:astronomy';

const Status = Enum.create({
  name: 'Status',
  identifiers: {
    OPENED: 0,
    CLOSED: 'asd',
    DONE: true,
    CANCELED: false
  }
});

Union type

One example of custom type is union. Union allows a field to accept values of different type. Let’s say you want to have a field which can accept Number or String. You can easily create a union type in Astronomy that will allow that. Let’s take a look at the basic example.

import { Class, Union } from 'meteor/jagi:astronomy';

const StringOrNumber = Union.create({
  name: 'StringOrNumber',
  types: [String, Number]
});

const Item = Class.create({
  name: 'Item',
  fields: {
    stringOrNumber: {
      type: StringOrNumber
    }
  }
});

Now you can validate documents of this class.

const item = new Item();
item.stringOrNumber = 'abc';
item.validate(); // Valid.
item.stringOrNumber = 123;
item.validate(); // Valid.
item.stringOrNumber = false;
item.validate(); // Invalid.

You can also pass an optional casting function to the union definition.

const StringOrNumber = Union.create({
  name: 'StringOrNumber',
  types: [String, Number]
  cast(value) {
    if (typeof value !== 'string') {
      return String(value);
    }
    return value;
  }
});

And use it in the standard way.

const item = new Item({
  stringOrNumber: false
}, {cast: true});
item.set({
  stringOrNumber: new Date()
}, {cast: true});

Default values

If you need to provide more information than just the field’s type - let’s say a default value, then you can do so. Take a look at the following example:

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: {
      type: String,
      default: ''
    }
  }
});

var user = new User();
user.firstName; // '' - empty string

If we don’t provide a default value for a field, then it will be set to undefined on document’s creation. We can’t use JavaScript objects for default values directly. If we want to do so, then we have to use function and return such an object. The function will be executed on document’s creation and returned value will be used as the default value.

NOTE: If you want to set a default value of a field to object (an array is also object) you should always use a function that returns such an object. This is important because values in JavaScript are passed by reference and we want every instance of the class to have its own copy of the object, not one that would be shared among all documents.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    address: {
      type: Object,
      default: function() {
        return {};
      }
    }
  }
});

Nested fields

MongoDB is a document-oriented database. This allows you to not only store plain values in fields also objects and arrays and Astronomy have a nice syntax for allowing that.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    address: {
      type: Object,
      default: function() {
        return {};
      }
    },
    phones: {
      type: [String],
      default: function() {
        return [];
      }
    }
  }
});

In this example, we’ve defined the address field that can store any JavaScript object and the phones field that can store arrays of strings. In the square brackets you can use any available type like: Number, Date, Object, Boolean.

Nested classes

In the previous example, we showed how to define a field to store a JavaScript object. But, what if we want to provide more schema for such a field. We may want to have city and state fields and check their validity.

import { Class } from 'meteor/jagi:astronomy';

const Address = Class.create({
  name: 'Address',
  /* No collection attribute */
  fields: {
    city: {
      type: String
    },
    state: {
      type: String
    }
  }
});

const User = Class.create({
  name: 'User',
  collection: Users,
  fields: {
    address: {
      type: Address
    }
  }
});

As you can see, we can use class constructor as a type. It’s true for all the classes defined with Astronomy. They are automatically registered as a new type.

Now let’s try defining the User class, that has the addresses field for storing an array of addresses.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  collection: Users,
  fields: {
    addresses: {
      type: [Address]
    }
  }
});

Transient fields

Some fields may be computed from the values of other fields instead of being persisted in the database. The good example is calculating age from a birth date. As the age changes during the time, the birth date is constant, so it’s why we should only store the birth date. The example below shows how to set the age field as transient and calculate its value.

NOTE: To calculate the age from the birth date we used the afterInit event. You will learn more about events in following sections of this documentation.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    birthDate: Date,
    age: {
      type: Number,
      transient: true
    }
  },
  events: {
    afterInit(e) {
      const doc = e.currentTarget;
      var birthDate = doc.birthDate;
      if (birthDate) {
        var diff = Date.now() - birthDate.getTime();
        doc.age = Math.abs((new Date(diff)).getUTCFullYear() - 1970);
      }
    }
  }
});

Immutable fields

If you want some field to be immutable you should set the immutable flag in the definition of a field. It won’t be possible to change field’s value once it has been persisted in the database. Let’s take a look at the example.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    createdAt: Date,
    immutable: true
  }
});

var user = new User();
// It's possible to set a value.
user.createdAt = new Date('2015-09-14');
// It's possible to change a value as long as it was not save into database.
user.createdAt = new Date('2015-09-15');
user.save();
// Now setting a value will be denied.
user.createdAt = new Date('2015-09-16');

Optional fields

In Astronomy all fields are required by default. To mark a field as optional use the optional property.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    address: {
      type: Object,
      optional: true
    }
  }
});

Not providing a value for such a field will not throw any validation error. However, when you provide value then it will be validated agains any rules you’ve defined.

Mapping fields

Sometimes you way want to map some field name stored in the collection into another field name. It may be a result of changes in the schema during a development of application. Let’s say we used to store phone number in the phoneNumber field but after some time we decided to change the field name to phone. Of course, we could just perform some query and just change field name. But there may be situations where we would like to connect two fields into one (which of course could also be done using some query) and we don’t want to mess with the current data in the database. Let’s take a look at the example how to map the phoneNumber field into phone.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    phone: {
      type: String,
      resolve(doc) {
        return doc.phoneNumber;
      }
    }
  }
});

And another example with joining the firstName and lastName fields into one fullName.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    fullName: {
      type: String,
      resolve(doc) {
        return doc.firstName + ' ' + doc.lastName;
      }
    }
  }
});

As you can see in both examples, we perform fields mapping by providing the resolve() method in the field’s definition. As the first argument, it receives raw data from a collection. Your responsibility is to return a new value for a field. It’s worth noting that, if we’re dealing with nested class, the the resolve method for such class will receive only data for a nested field.

Casting values

In the Setting and getting values section will described how to cast values being set in a document. Each field type comes with its own default casting function. Here are examples of how given values will be casted using default casting functions.

String
123 => "123"
true => "true"
false => "false"
{foo: "bar"} => {foo: "bar"}
Number
"123" => 123
true => 1
false => 0
{foo: "bar"} => {foo: "bar"}
Date
572137200000 => Date(1988, 1, 18)
"1988-02-18" => Date(1988, 1, 18)
"2/18/1988" => Date(1988, 1, 18)
true => true
false => false
{foo: "bar"} => {foo: "bar"}
Boolean
0 => false
1 => true
"false" => false
"FALSE" => false
"0" => false
"TRUE" => true
"abc" => true
{foo: "bar"} => {foo: "bar"}

As you can see, objects are not casted and only plain values goes through the casting function. There are also several nice tricks for easier casting of values coming from the forms, like the "FALSE" string will be casted to the false value.

The values undefined and null are not casted.

We also need to discuss how empty strings ("") are casted to each type depending on whether a field is optional or required.

// Required fields:
item.set('number', '', {cast: true}); // Casts to 0
item.set('boolean', '', {cast: true}); // Casts to false
item.set('date', '', {cast: true}); // Does not cast
item.set('object', '', {cast: true}); // Does not cast
item.set('list', '', {cast: true}); // Does not cast
// Optional fields:
item.set('number', '', {cast: true}); // Casts to null
item.set('boolean', '', {cast: true}); // Casts to null
item.set('date', '', {cast: true}); // Casts to null
item.set('object', '', {cast: true}); // Casts to null
item.set('list', '', {cast: true}); // Casts to null

In most cases default casting functions will fit your needs, however there are situations when you would like to define custom casting function. Astronomy 2.3 allows defining custom casting functions. Let’s take a look at the example.

const pattern = {
  year: Number,
  month: Number,
  day: Number
};
const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    birthDate: {
      type: Date,
      cast(value) {
        if (Match.test(value, pattern)) {
          return new Date(
            value.year,
            value.month,
            value.day
          );
        }
        return value;
      }
    }
  }
});

In this example, when you try assigning an object with the “year”, “month”, “day” properties being numbers, the casting function will cast such an object into a date object.

Setting and getting values

Once you have defined a list of fields, you may want to set and get some values from a document. Let’s check how to set values of a document.

Setting values

There is nothing special about setting values of a document. You may do it in a way you would do it with any JavaScript object.

const user = new User();
user.firstName = 'John';
user.address.city = 'San Francisco';

However there is the set() helper method that can help you with setting nested fields and multiple fields at once.

const user = new User();
user.set('address.city', 'San Francisco');
user.set({
  firstName: 'John',
  lastName: 'Smith'
});

You may also set values on document creation.

const user = new User({
  firstName: 'John',
  lastName: 'Smith',
  address: {
    city: 'San Francisco',
    state: 'CA'
  }
});

Pulling values from arrays

To modify data in Astronomy documents you don’t have to use the set() method. The set() method is only required when you want to perform some additional operation on a data being set like for example casting. It’s very common to remove elements from arrays, so we will provide two examples of how to implement it. In the first example we will use pure JS and in the second one the lodash library.

// Pure JS
const post = Post.findOne();
// Delete comment which author is Jagi
const index = post.comments.findIndex((comment) => comment.author === 'Jagi');
post.comments.splice(index, 1);
post.save();
// Using lodash
const post = Post.findOne();
// Delete comment which author is Jagi
post.comments = _.remove(post.comments, (comment) => comment.author === 'Jagi');
post.save();

Casting values

Values being set on a document are not casted to the fields’ types by default, you have to force this behavior. The set() function takes an options object as the last argument.

user.set({
  firstName: 123 // Will be casted to the "123" string
}, {
  cast: true
});

In the example above the 123 number will be converted to the "123" string, if the type of the firstName field is String.

You can also cast values on document construction, by passing the options object as the last argument of a document constructor.

const user = new User({
  firstName: 123 // Will be casted to the "123" string
}, {
  cast: true
});

Cloning values

When setting values using the set method or constructor all values will be cloned by default. In most cases it’s expected behavior as it makes sure that you don’t copy references, which might cause hard to find bugs. However, if you know what you’re doing and you want to improve performance of your application you can turn off this option.

const user = new User({
  firstName: 'John'
}, {
  clone: false // It's not needed to clone values being set as they are defined inline.
});

user.set({
  lastName: 'Smith'
}, {
  clone: false // It's not needed to clone values being set as they are defined inline.
}

Merging values

When setting value for a field, the old value will be overridden by a new one. It’s not always expected behavior in a fields of the object/class type. Let’s take the address field as an example and try updating the state field.

const user = new User({
  address: {
    city: 'New York',
    state: 'CA'
  }
});
const addressData = {
  state: 'CA'
};
user.set('address', addressData);

In the example above an entire address field will be overridden, also the city field. To make it work we would have to tell Astronomy explicitly what field we want to update.

user.set('address.state', addressData.state);

In this example it will work as expected but sometimes we would just like to use syntax from the previous example and just merge objects instead overriding. In Astronomy 2.3, we’ve introduced the merge option which allows that. Let’s take a look at the example.

const addressData = {
  state: 'CA'
};
user.set('address', addressData, {
  merge: true
});

Right now the address field won’t be overridden and instead it will be merged with the object being set. We can also use the merge option in the following scenario.

const userData = {
  address: {
    state: 'CA'
  }
};
user.set(userData, {
  merge: true
});

It will merge objects at all levels.

Default values

When you try to set some field’s value to undefined using the set method but the field has default value defined, it will using default value for this field (it won’t use default value if you’re doing direct assignment). You might want to turn off this option.

// Default value for the "address" field is "new Address()".
const user = User.findOne();
user.set('address', undefined); // It will set field to "new Address".
user.set('address', undefined, {defaults: false}); // It will set field to "undefined".

Getting values

Getting values is similar to setting them. We also have here the get() helper method that helps with getting multiple and nested values.

const user = User.findOne();
user.firstName; // Get first name.
user.address.city; // Get nested field.
user.get('address.city'); // Get nested field with helper method.
user.get(['firstName', 'lastName']); // Get multiple fields.

Getting modified values

There are four methods that help with getting modified fields/values.

  • isModified([fieldName])

When called without arguments doc.isModified() will tell you if a document was modified. However, you can pass a field name user.isModified('firstName') to determine if a single field was modified. You can also pass nested field name user.isModified('address.city').

  • getModified([old])

Returns list of all fields that have been modified (including nested fields).

  • getModifiedValues([options])

Returns values of all modified fields as an object with keys being fields names and values being fields values. The method can take an optional options object where you can specify if you want to retrieve old values before modification. You can also retrieve raw values of nested classes if they were modified.

user.getModifiedValues({ old: true, raw: true });
/*
{
  address: {
    city: "San Francisco",
    state: "CA"
  }
}
*/
  • getModifier()

Returns a modifier for modifications that were performed from the last document save.

const user = User.findOne();
user.firstName = 'John';
user.lastName = undefined;
user.getModified();
/*
{
  $set: {
    firstName: 'John'
  },
  $unset: {
    lastName: ''
  }
}
*/

Getting raw values

The raw() method is responsible for getting plain values. You can use it to get a raw copy of the entire document, or a copy of a nested field. This means that even if a given field is defined as a nested Astronomy class, it will return a plain JavaScript object instead.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    name: String,
    address: {
      type: Address,
    }
  }
});

const user = User.findOne();
// Getting a plain value of the "address" field.
user.raw('address'); // { city: 'San Francisco', state: 'CA' }
// Getting a plain copy of multiple fields
user.raw(['name', 'address']); // { name: 'John Smith', address: { city: 'San Francisco', state: 'CA' } }
// Getting a plain copy of the entire document.
user.raw(); // { _id: 'uKqB7m2uZWi3zncR2', name: 'John Smith', address: { city: 'San Francisco', state: 'CA' } }

Storing Documents

In this section we will focus on documents storage. We will describe saving and removing documents.

Saving

MongoDB provides the insert method that allows document inserting and the update method that modifies an already stored document. In Astronomy we don’t need to call these methods directly. Astronomy documents are aware of their states, so we can replace insert and update methods with the one save() method. Let’s take a look at an example showing how to insert a new document and update an existing one.

var user = new User();
user.firstName = 'John';
user.lastName = 'Smith';
user.save(); // Insert document.

user.firstName = 'Łukasz';
user.lastName = 'Jagodziński';
user.save(); // Update document.

As you can see, we’ve used the save() method for both insertion and modification of a document. Every Astronomy document knows whether it should be inserted or updated, so you only need to call save().

Is a document new or not?

There maybe situations when you would like to know whether a document is new (not stored in collection yet) or not new (already stored in collection). Before version 2.3.4 you could use doc._isNew property for that purpose. From version 2.3.4 this property is deprecated and will be removed in version 3.0. From know there is a new way of telling whether a document is new. Let’s take a look at the example below.

const Item = Class.create({
  name: 'Item',
  collection: new Mongo.Collection('items'),
  events: {
    afterInsert(e) {
      const doc = e.target;
      Item.isNew(doc);
    }
  }
});

As you can see, from now every class has the isNew() method to which you pass a document that you want to check.

Server only call

By default, when you execute the save() method on the client, it will perform the save operation in both environments client and server. Thanks to that you don’t have to create Meteor methods, however it’s highly recommended for security reason. But more about that in the Security section.

var user = new User();
user.save(); // Insert a document on the client and server.

There are situations when you don’t want to perform simulation on the client. The save() method takes an object of options as the first argument. One of the options is simulation which you have set to false, to not perform client simulation.

var user = new User();
user.save({
  simulation: false // Insert only on the server.
});

Updating only selected fields

The save() method as the first argument can also take a list of options and one of them is fields. By providing array of fields, we can force Astronomy to update only provided fields even when we modified more of them.

var user = User.findOne();
user.save({
  fields: ['firstName', 'lastName'] // Update only first and last name.
});

It’s important to note here that the fields option does not work with the insert operation.

Stopping validation on the first error

The save() method can also take a list of options as the first argument and one of them is stopOnFirstError. By default it’s set to true, so it will stop further validation on the first validation error. However, if you want to get all validation errors, then you can set it to false.

var user = User.findOne();
user.save({
  stopOnFirstError: false // Don't stop on the first validation error.
});

Casting values on save

The save() method can also take a list of options as the first argument and one of them is cast. By default it’s set to false, so it will not cast values using casting functions. There may be situations when you want to postpone casting values until the save operation. Let’s take adding a new phone number to the user document.

const user = User.findOne();
user.phones.push(phoneFormData);

As you can see in the example above, we’ve just used the push() method of the Array object. It will not cast values from the phoneFormData variable. In Astronomy to do so, we would have to use the set() method, so we would need to know an index of the newly added phone document. Sample code could look like in the example below.

const user = User.findOne();
user.set(`phones.${user.phones.length}`, phoneFormData, {
  cast: true
});

As you can see, it’s not perfect solution. So instead of casting values on assignment, we can postpone it to the save operation.

const user = User.findOne();
user.phones.push(phoneFormData);
user.save({
  cast: true
});

Callback function

The save() method takes a callback function as the last argument. The callback function will be called when document storage is finished or on when any error occurred on the client or server. The first argument of the callback function is error object and the second one is response from the server. On insert the response is ID of the inserted document, and on update it’s number of modified documents.

var user = new User();
user.save(function(err, id) {
});

Removing

Removing documents is as simple as saving them. It works the same way as the save() method - it executes removal operation in both client and server. Let’s take a look at an example.

var user = User.findOne();
user.remove();
// Or with callback function.
user.remove(function(err, result) {
});

Fetching documents

Once we have Astronomy documents stored in a collection, we may want to retrieve them. The old/standard way of fetching documents does not change when using Astronomy. So calling Collection.findOne() will just return plain JavaScript object. In Astronomy 1.0 it used to transform documents to Astronomy class instances. In Astronomy 2.0, we use Class.findOne() to retrieve Astronomy documents. It works the same way as Collection.findOne() but documents are transformed. Let’s take a look at an example.

import { Class } from 'meteor/jagi:astronomy';
import { Mongo } from 'meteor/mongo';

const Users = new Mongo.Collection('users');
const User = Class.create({
  name: 'User',
  collection: Users,
  /* ... */
});
var user = new User();
user.save();

/* ... */

// Fetching document from the collection.
// It's not a plain JavaScript object, it's an instance of the User class.
var user = User.findOne(); // Get single document.
var users = User.find(); // Get cursor.
user.firstName = 'John';
user.save();

Find options

The same as in find and findOne methods from Mongo.Collection, the Astronomy versions can take options as the second argument. Here is the list of extra options:

  • disableEvents - by setting it to true, we turn off beforeFind and afterFind events, about which we will talk more in the Events section.
var user = User.findOne({}, {
  disableEvents: true
});
  • children - if you are fetching documents from the class that has some child classes, then you can decide if you want to fetch children or not. You can read more about inheritance in the Inheritance section. By default all children are fetched however you can tell Astronomy how many levels deep it should look for children.
Parent.find(); // Fetch children, grand children and so on
Parent.find({}, {
  children: false // Do not fetch children
});
Parent.find({}, {
  children: 1 // Only fetch direct children
});
Parent.find({}, {
  children: 2 // Fetch direct children and grand children
});
  • defaults - option is set to true by default and might cause overriding some fields with default values on document update, if you’re subscribed only to some set of fields. In such case, it’s recommended to set it to false which will fill fields that you’re not subscribed to with undefined values instead of default ones. This options should be set to false by default but because of the backward compatibility we can’t change it, so you have to be aware of this issue.
// both.js
const Post = Class.create({
  name: 'Post',
  collection: Posts,
  fields: {
    title: String,
    tags: {
      type: [String],
      default() {
        return [];
      }
    }
  }
});

// server.js
Meteor.publish('posts', function() {
  Post.find({}, {fields: {title: 1}});
});

// bad_client.js
Meteor.subscribe('posts');
const post = Post.findOne();
post.name = 'New name';
post.getModifier(); // {$set: {name: 'New name', tags: []}} - it will override tags
post.save();

// good_client.js
Meteor.subscribe('posts');
const post = Post.findOne({}, {defaults: false});
post.name = 'New name';
post.getModifier(); // {$set: {name: 'New name'}} - it will not override tags
post.save();

Helpers

By adding helpers to your class you can make a document to be alive. A user can user.greet() you and a dog can dog.bark(). Let’s take a look at the example of adding the fullName() helper to the User class.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: String,
    lastName: String
  },
  helpers: {
    fullName(param) {
      var fullName = this.firstName + ' ' + this.lastName;
      if (param === 'lower') {
        return fullName.toLowerCase();
      } else if (param === 'upper') {
        return fullName.toUpperCase();
      }
      return fullName;
    }
  }
});

var user = new User();
user.firstName = 'John';
user.lastName = 'Smith';
user.fullName();  // Returns "John Smith"

A context this in a helper is a document instance, so you can access other fields of a document. The fullName() helper takes the firstName and lastName properties and join them with a space character and returns such a string. As you can see, we can also pass parameters to helpers.

Using helpers in Blaze templates

You can use Astronomy helpers in Blaze templates as normal helpers or properties. Let’s take a look at the example of printing a full name of a user.


<div>Full name: {{user.fullName}}</div>

You can also pass parameters to helpers:


<div>Full name: {{user.fullName "upper"}}</div>

Meteor methods (>= 2.2.0)

You can define Meteor methods directly in your class definition. Thanks to that, you can better organize your code. Executing method from the client will call it in both client and server. In previous Astronomy version you had to define helpers/methods and later Meteor methods that wrap these helpers. From version 2.2.0 you can minimize amount of code by putting all the logic in Meteor methods defined on the class level. Let’s take a look how to define them.

// user.js
import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: String,
    lastName: String
  },
  meteorMethods: {
    rename(firstName, lastName) {
      this.firstName = firstName;
      this.lastName = lastName;
      return this.save();
    }
  }
});

Now, we will execute Meteor method in the same way we execute Astronomy helpers.

// client.js
const user = User.findOne();
user.rename('John', 'Smith', (err, result) => {
});

As you can see, we pass three arguments to the rename() method. The first two arguments are passed to the method. The last one is a callback function which will be execute after server response with the Meteor method result as the second argument of the callback function.

Server only methods

You might want to hide some method logic from the client and define Meteor method only on the server.

// user.js
import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: String,
    lastName: String
  }
});
// server.js
User.extend({
  meteorMethods: {
    rename(firstName, lastName) {
      this.firstName = firstName;
      this.lastName = lastName;
      return this.save();
    }
  }
});

In this case calling the rename method on the client will throw error.

// client.js
const user = User.findOne();
// Throws "Uncaught TypeError: user.rename is not a function()".
user.rename('John', 'Smith', (err, result) => {
});

So how to call this method from the client? There are two special methods designed to help with that. They are callMethod() and applyMethod(). They work in similar way as Meteor.call() and Meteor.apply()

// client.js
const user = User.findOne();
user.callMethod('rename', 'John', 'Smith', (err, result) => {
});
// or
user.applyMethod('rename', ['John', 'Smith'], (err, result) => {
});

The difference between these two method is that the second one takes an array of arguments.

The “this” context

The this context in method is a document instance. Astronomy methods does not execute this.save() automatically, so if you want to save changes made to the document, you have to execute the save() method by yourself.

Method invocation object

Sometimes you may need to get method’s invocation object, giving you access to such properties like isSimulation, userId or unblock() method which are available in Meteor methods in the this context. To get the current invocation object you have to call DDP._CurrentInvocation.get().

import { DDP } from 'meteor/ddp-client';

User.extend({
  meteorMethods: {
    rename(firstName, lastName) {
      const invocation = DDP._CurrentInvocation.get();
      invocation.isSimulation;
      invocation.unblock();
      invocation.userId;
      /* ... */
    }
  }
});

Events

Astronomy is equipped with a full-fledged events system including events propagation. There are several predefined events but behaviors and modules creators can create their own events. All events are defined in the class schema. Let’s take a look at the example.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* */
  events: {
    beforeSave(e) {
      /* Do something before saving document */
    }
  }
});

As you can see, an event handler function receives an event object as the first argument. It contains very useful data like event name (type property) or event occurrence time (timeStamp property) and more discussed below.

Accessing document

There are two properties in the event object through which you can access a document on which event occurred. There are target and currentTarget.

The e.target property stores a document on which event was originally triggered. So if you execute user.save() then in the beforeSave event the target property will the user document.

The e.currentTarget property stores a document on which event is actually executed. To better understand that let’s take our common example with User and Address classes. Let’s see the schema for the Address class which is a nested class in the User.

import { Class } from 'meteor/jagi:astronomy';

const Address = Class.create({
  name: 'Address',
  fields: { /* ... */ },
  events: {
    beforeSave(e) {
      e.currentTarget;
    }
  }
});

Now, when you execute user.save() it will first trigger the beforeSave event on the user document (all beforeSave handlers for User will be executed) and later it will trigger the beforeSave event on each nested document including user.address (all beforeSave handlers for Address will be executed). The e.currentTarget property in the beforeSave handler in the Address class will point to the user.address nested document and e.target to the user document.

Taking all that into account, it’s more likely that you will use e.currentTarget than e.target. The e.target property is most often used to retrieve a “parent” document from the nested one.

The “trusted” property

The e.trusted property is very important from the security point of view. You can read more about it in the Security section. In this moment, you have to know that the trusted property indicates if e.g. the save operation was initiated on the server or not. If it was initiated on the server that it’s “trusted” if on the client then it’s not and we should validate user’s permissions.

Events propagation

Events propagation is a mechanism of propagating an event from the top document level down to the level of the most nested document. When you consider the User class that has the address nested property of the Address type, then an event triggered on the level of the User class will go down to the Address class and down if the Address class has any nested fields.

In any moment, we can stop execution of the further event handlers by calling the e.stopPropagation() method on an event object passed to the event handler. Thanks to that, event will not propagate to the nested documents.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* */
  fields: {
    address: {
      type: 'Address'
    }
  },
  events: {
    beforeSave(e) {
      e.stopPropagation();
    }
  }
});

const Address = Class.create({
  name: 'Address',
  /* ... */
  events: {
    beforeSave(e) {
      // This event will never get called because we stopped propagation.
    }
  }
});

There is also the e.stopImmediatePropagation() method on the event object that not only stops propagation but also prevent from executing other events of the same type and on the same level.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* */
  fields: {
    address: {
      type: 'Address'
    }
  },
  events: {
    beforeSave: [
      function(e) {
        e.stopImmediatePropagation();
      },
      function() {
        // This event will never get called.
      }
    ]
  }
});

Preventing default

There are processes you may want to prevent from occurring. The example of such process may be preventing the save operation, however it’s more likely that you will use it in some custom behavior or module with your custom events. For the proof of concept let’s take a look at the example.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* */
  events: {
    beforeSave(e) {
      // Prevent document save.
      e.preventDefault();
    }
  }
});

var user = new User();
// Document won't be saved.
user.save();

NOTE: It’s worth noting that the preventDefault() method does not stop event propagation.

Events list

Initialization events

There are two events that are triggered on a document initialization:

  • beforeInit
  • afterInit

The beforeInit event is triggered just after a new document instance is created. During this event all document’s fields are empty and you shouldn’t set or modify any field value. This event is mostly used by behaviors and modules.

The afterInit event is triggered after a document is fully created and filled with data. You can freely modify fields’ values and do other changes.

Storage events

There are several storage events that are triggered on a document save, update and remove. Let’s take a look at what events are triggered on what operation and in what order.

Insert:

  • beforeSave
  • beforeInsert
  • Actual document insertion is performed
  • afterInsert
  • afterSave
var user = new User();
u.save();

Update:

  • beforeSave
  • beforeUpdate
  • Actual document update is performed
  • afterUpdate
  • afterSave
var user = User.findOne();
u.firstName = 'Smith';
u.save();

Remove:

  • beforeRemove
  • Actual document removal is performed
  • afterRemove
var user = User.findOne();
u.remove();

Find events

There are two events being emitted when retrieving documents from collection using Class.find() or Class.findOne():

  • beforeFind
  • afterFind
User.findOne(); // Both events triggered

An event object passed to the beforeFind event handler contains two additional properties selector and options. They are two arguments passed to the findOne() or find() method. In an event handler you can modify selector or passed options. The softremove behavior is using it to limit fetching documents to only those that were not soft removed.

An event object passed to the afterFind event handler contains three additional properties selector, options and result. So, in this event handler you can additionally modify result of a find operation.

EJSON events

There are two events that maybe handy for behaviors and modules authors:

  • toJSONValue
  • fromJSONValue

These events are used to EJSONify documents and send them over DDP protocol.

Indexes

To speed up database access, we should create indexes on the fields we are searching on or sorting by. By default index is created only for the _id field. We can define a single field index in a definition of the field or multi fields index in the indexes property of the class schema. Let’s see examples of both.

Single field indexes

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    birthDate: {
      type: Date,
      // Define an index (ascending order) for the "birthDate" field.
      index: 1
    }
  }
});

The value 1 under the index property is an order in which index will be stored. In this case, it doesn’t matter if we use ascending (1) or descending (-1) order because MongoDB can easily iterate through the one key indexes in both directions. However it does matter in the case of the multi-key indexes. In the field definition we can only create single field indexes.

MongoDB supports several different index types including text, geospatial, and hashed indexes. If you want to use them you have to provide a type name instead of the 1 or -1 values. You can read about index types in the MongoDB documentation.

Let’s take a look at how to define the text index.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    lastName: {
      type: String,
      // Define the "text" index on the "lastName" field.
      index: 'text'
    }
  }
});

Multiple fields indexes

We define multi fields indexes under the indexes property in the class schema. Let’s take a look at the example:

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: String,
    lastName: String
  },
  indexes: {
    fullName: { // Index name.
      fields: { // List of fields.
        lastName: 1,
        firstName: 1
      },
      options: {} // Mongo index options.
    }
  }
});

The index name is fullName. An index definition consists of two objects: list of fields and list of options.

A value of the fields property is an object with the key-value pairs where the key is a field name and the value describes the type of index for that field. For an ascending index order on a field, specify a value of 1 and for descending index order, specify a value of -1. You can also use text, geospatial, and hashed types.

The options property let’s you specify details of your index like index uniqueness. You can read more about available options in the MongoDB documentation.

Example of defining unique index:

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: String,
    lastName: String,
    email: String
  },
  indexes: {
    email: {
      fields: {
        email: 1
      },
      options: {
        unique: true
      }
    }
  }
});

Inheritance

Creating a new class by inheriting from other class can give you several benefits:

  • Each instance of a child class is also instance of a parent class
  • You can store instances of different classes in the same collection
  • You can fetch only documents of a given type even if they are stored in the same collection

At first, let’s check how to inherit a class.

import { Class } from 'meteor/jagi:astronomy';

const Parent = Class.create({
  name: 'Parent',
  fields: {
    parent: String
  }
});

const Child = Parent.inherit({
  name: 'Child',
  fields: {
    child: String
  }
});

Now, we can check if an instance of a child class is also an instance of a parent class.

var child = new Child();
child instanceof Child; // true
child instanceof Parent; // true

Many classes in the same collection

Previous example showed how to inherit class that does not have related collection in which documents are stored. Let’s create class that gets stored in a collection. We will inherit from it and store documents of both classes in the same collection.

import { Class } from 'meteor/jagi:astronomy';
import { Mongo } from 'mongo';

const Items = new Mongo.Collection('items');
const Parent = Class.create({
  name: 'Parent',
  collection: Items,
  // Class discriminator.
  typeField: 'type',
  fields: {
    parent: String
  }
});

Child = Parent.inherit({
  name: 'Child',
  fields: {
    child: String
  }
});

Notice two things that have changed.

  • We provided a collection object (Items) in the parent class definition
  • We provided a value of the typeField property in the parent class definition

The collection object is quite obvious, it’s where instances of our classes will be stored.

The typeField property tells Astronomy what field name should be added to definitions of parent and child classes that determines type of a fetched object. To understand it better let’s take a look at the example.

var child = new Child();
child.type; // "Child"

var parent = new Parent();
parent.type; // "Parent"

As you can see in both classes Astronomy added the type field which stores “Child” and “Parent” strings for Child and Parent parent classes accordingly. A value of this field will be stored with an instance and when fetching the given document from the collection, the transform function will automatically fetch an instance of proper class.

Executing parent helpers from the child ones

It’s not super easy and intuitive to call parent helper from the child one. We can’t just call super.helperOnParent([arguments]);. Here is an example of how to achieve that using Astronomy:

Child.inherits({
  name: 'Child',
  helpers: {
    helperName() {
      Parent.getHelper('helperName').apply(this, arguments);
    }
  }
});

Extending class

There are situations, when we want to have some differences in a class (additional fields, helpers, methods or events) depending on an environment. For example, we may want to have some fields that are only available on the server. Let’s take a look at how we can extend a class to achieve that.

// Client and server.
import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: String,
    age: Number
  }
});

// Server only.
if (Meteor.isServer) {
  User.extend({
    fields: {
      private: String
    }
  });
}

As you can see, we used the User.extend() method to add server only field to the User class. The only argument of the extend() method is a class schema that extends the class.

Validation

Validators allows checking validity of fields’ values. For instance, we can check whether a value of a given field is a correct email string or if it matches a regular expression. By default Astronomy checks validity of fields’ types.

Validating document

To validate a document you have to call the validate() method from the level of a document. The first argument of the validate method is optional list of options.

var user = new User();
user.validate({
  fields: ['firstName'],
  stopOnFirstError: false,
  simulation: false,
  cast: true
});

As you can, see the set of options is similar to the set of option in the save() method.

  • fields - List of fields to validate.
  • stopOnFirstError - Validation should stop after the first validation error. It’s true by default.
  • simulation - Validation should be also simulated on the client. It’s true by default.
  • cast - Values should be casted on validation. It’s false by default.

Casting values of validation

There may be a need for postponing casting values until the validate or save operation. If you values are coming from the form, then number values will probably be in a form of the string. In such cases, you may want to cast values to numbers and later only throw validation errors when this number does not meet defined validation rules. Let’s take a look at the example usage.

var user = User.findOne();
user.phones.push(phoneFormData);
user.validate({
  cast: true
}, function(err) {
  // Do something with the error if any.
});

Callback function

The second argument in the validate() method is a callback function, which behave in the same way as the one in the save() method.

// On the client.
var user = new User();
u.validate(function(err) {
  if (err) {
    // Validation error.
  }
});

The error object coming from the server may not always has be a validation error, so we need some mechanism of determining what error type it is. We can do it by checking the err.error property of the error object. It should be equal "validation-error". The other way of checking it is using the ValidationError.is() method. Let’s see example using both approaches.

import { ValidationError } from 'meteor/jagi:astronomy';

// On the client.
var user = new User();
u.validate(function(err) {
  // First approach.
  if (err && err.error === 'validation-error') {
  }
  // Second approach.
  if (ValidationError.is(err)) {
  }
});

Adding validators

There are two ways of adding validators to the class. You can define them on the class level on the field’s definition level. Let’s take a look at the example of both.

Validators on the field level

You can define validator on a field level to keep field definition together with its validators. It’s much more readable that way. You define validators for a field by providing the validators property.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: {
      type: String,
      validators: [{
        type: 'minLength',
        param: 3
      }]
    }
  }
});

As you can see, for the validators property we provide array of objects, where each one has one mandatory attribute type. In this example, we used the minLength validator, which checks if a string is at least X characters long. The param attribute tells how long a given text should be.

There are validators that does not take any param like type validators (string, number, data) so it’s not mandatory for each validator. However, the minLength validator requires this parameter to be passed.

Validators on the class level

This type of defining validators is much less used. This time, you define validators on the class level under the validators property. Let’s take a look at the example.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  validators: [
    firsName: [{
      type: 'minLength',
      param: 3
    }]
  ]
});

This situation is similar to previous one, however we only have here section dedicated to validation. We don’t provide any field properties beside validators data.

Reusing validators

Sometimes you may notice that you repeat the same set of validators over and over again. There is a possibility to reuse validators.

import { Class } from 'meteor/jagi:astronomy';

var min3max10 = [{
  type: 'minLength',
  param: 3
}, {
  type: 'maxLength',
  param: 10
}];

const User = Class.create({
  name: 'User',
  /* */
  validators: {
    firstName: min3max10,
    lastName: min3max10
  }
});

Function as a validator param

There are situations when you may want resolve param value on runtime as you may not know the actual param value on the schema definition time. For such cases there is a special resolveParam validator property. You can provide function that will be resolved on validation. Let’s take a look at the example.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    birthDate: {
      type: Date,
      validators: [{
        type: 'lte',
        resolveParam: function() {
          let date = new Date();
          return date.setFullYear(date.getFullYear() - 18);
        }
      }]
    },
    firstName: {
      type: String,
      validators: [{
        type: 'maxLength',
        resolveParam(args) {
          return args.doc.lastName.length - 1;
        }
      }]
    }
  }
});

We have here two examples of usage of the resolveParam function. In the birthDate field, we used it for the lte validator, which stands for less than or equal. We just calculate at which date should at least given user be born to be 18 years old. So, this param depends on the current date.

In the second example, in the firstName field we used the resolveParam function in the maxLength validator to tell that the firstName has to be shorter than the lastName (I know it’s bad example). So, we have to access document’s another field. We can do so, by accessing arguments object passed to the resolveParam method. It contains such properties like.

  • args.doc - Document being validated
  • args.name - Field name being validated
  • args.nestedName - Nested field name being validated
  • args.value - Current field’s value

Generating errors

There are several ways of generating an error message when validation fails. Of course, you can always use default error messages that come with validators. However, if you want something more application specific or when you need messages translation, it may be a good idea to generate custom error message. In this section, we will discuss all possible ways of the error message generation.

String validation message

The simplest and less flexible way of generating error message is passing a string as the message property in the validator’s definition. Let’s take a look at the example.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: {
      type: String,
      validators: [{
        type: 'minLength',
        param: 3,
        message: 'The first name is too short!'
      }]
    }
  }
});

As you can see, we passed the "The first name is too short!" string and that’s the all we can do here. We can’t customize it. So, let’s do try generating error message dynamically.

Generating an error message

The another attribute of the validator is resolveError, which has to be a function. It allows resolving error message on validation time. Let’s take a look at the example.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  fields: {
    firstName: {
      type: String,
      validators: [{
        type: 'minLength',
        param: 3,
        resolveError({ name, param }) {
          return `Length of "${name}" has to be at least ${param}`;
        }
      }]
    }
  }
});

As you can see, we have access to some parameters when using the resolveError method. In our example we used name and param and composed error message using them. Remember to always return an error message. Beside two mentioned params there are more to work with.

  • className - a class name of a document being validated,
  • doc - a document being validated,
  • name - a field name being validated,
  • nestedName - a nested field name being validate,
  • value - value of a field being validated,
  • param - param passed to the validator

Generating error message for required fields

When a field is required, then it has the required validator assigned to it. In such case, you can’t just provided another required validator for such a field and provide custom error message. In this section we will describe process of generating error messages for fields that already have some validators assigned.

For that purpose, there is special resolveError property declared on the class level which can generate error message for any field in a class. Let’s take a look at the example below.

const i18n = {
  messages: {
    'firstName-required': 'Это обязательное поле.'
  },
  get(key) {
    return this.messages[key];
  }
};

const User = Class.create({
  name: 'User',
  fields: {
    firstName: {
      type: String
    }
  },
  resolveError({nestedName, validator}) {
    return i18n.get(`${nestedName}-${validator}`);
  }
});

As you can see, the resolveError property works the same way as its equivalent in field’s definition. So you can access the same properties of a field being validated. We just need to return error message from the function. We also introduced simple mechanism for i18n messages. You can improve it to add another languages.

Validators list

Here is a list of all validators predefined in Astronomy and information about params that they take.

string

Checks if value is a string.

{
  type: 'string'
  // No param.
}

number

Checks if value is a number.

{
  type: 'number'
  // No param.
}

integer

Checks if value is an integer.

{
  type: 'integer'
  // No param.
}

boolean

Checks if value is a boolean.

{
  type: 'boolean'
  // No param.
}

array

Checks if value is an array.

{
  type: 'array'
  // No param.
}

object

Checks if value is an object.

{
  type: 'object'
  // No param.
}

date

Checks if value is a date.

{
  type: 'date'
  // No param.
}

required

Checks if value is not null or undefined.

{
  type: 'required'
  // No param.
}

null

Checks if value is null.

{
  type: 'null'
  // No param.
}

notNull

Checks if value is not null.

{
  type: 'notNull'
  // No param.
}

length

Checks length of value. Works with any value having the length property like arrays or strings.

{
  type: 'length'
  param: 5
}

minLength

Checks minimal length of value. Works with any value having the length property like arrays or strings.

{
  type: 'minLength'
  param: 5
}

maxLength

Checks maximum length of value. Works with any value having the length property like arrays or strings.

{
  type: 'maxLength'
  param: 5
}

gt

Checks if value is greater than param. Works with any value which casts to numbers like number, dates and strings.

{
  type: 'gt'
  param: 5
}

gte

Checks if value is greater than or equal to param. Works with any value which casts to numbers like number, dates and strings.

{
  type: 'gte'
  param: 5
}

lt

Checks if value is less than param. Works with any value which casts to numbers like number, dates and strings.

{
  type: 'lt'
  param: 5
}

lte

Checks if value is less than or equal to param. Works with any value which casts to numbers like number, dates and strings.

{
  type: 'lte'
  param: 5
}

email

Checks if value is a correct email address.

{
  type: 'email'
  // No param.
}

choice

Checks if value of the field is one of the values provided as a param.

fields: {
  sex: {
    type: String,
    validators: [{
      type: 'choice',
      param: ['male', 'female']
    }]
  }
}

equal

Checks if value is equal to the one in a param.

{
  type: 'equal'
  resolveParam() {
    return 'Should be equal to this value';
  }
}

regexp

Checks if value matches regular expression passed as a param.

{
  type: 'regexp',
  param: /^[a-zA-Z0-9]+$/
}

and

Checks if all of validators passed as a param passes validation.

{
  type: 'and',
  param: [{
    type: 'gt',
    param: 6
  }, {
    type: 'lt',
    param: 9
  }]
]

or

Checks if any of validators passed as a param passes validation.

{
  type: 'or',
  param: [{
    type: 'lt',
    param: 6
  }, {
    type: 'gt',
    param: 9
  }]
]

every

Checks if any value in an array passes validation.

fields: {
  tags: [String],
  validators: [{
    // Each tag in an array...
    type: 'every',
    param: [{
      // ... has to be at least 3 characters long and...
      type: 'minLength',
      param: 3
    }, {
      // ... up to 40 characters long.
      type: 'maxLength',
      param: 40
    }]
  }]
}

has

Checks if a property is present in an object.

fields: {
  address: {
    type: Object,
    validators: [{
      // The non-schema address object has to have the "city" property.
      type: 'has',
      param: 'city'
    }]
  }
}

includes

Checks if an array or object contains a given value.

fields: {
  tags: {
    type: [String],
    validators: [{
      // The "tags" array has to contain the "test" tag.
      type: 'includes',
      param: 'test'
    }]
  }
}

Creating validators

The set of default validators may not be enough for you, so you can create your own validators. We will show the process of creating validator on the example of the maxLength validator. Here is the whole code of the validator.

import _ from 'lodash';
import { Validator } from 'meteor/jagi:astronomy';

Validator.create({
  name: 'maxLength',
  parseParam(param) {
    if (!Match.test(param, Number)) {
      throw new TypeError(
        `Parameter for the "maxLength" validator has to be a number`
      );
    }
  },
  isValid({ value, param }) {
    if (!_.has(value, 'length')) {
      return false;
    }
    return value.length <= param;
  },
  resolveError({ name, param }) {
    return `Length of "${name}" has to be at most ${param}`;
  }
});

We have two mandatory attributes. The first one is the name attribute. Under this name the validator will be added to the global Validators object.

The second mandatory attribute is the isValid function. It should return a boolean value indicating if a value of a given field passes validation. As you can see, we also partially check validity of a value passed as an argument. We make sure that the length property is present. The isValid function receives several useful params as a first argument object.

  • doc - a document being validated
  • name - a field name being validated
  • nestedName - a nested field name being validated
  • value - value of a field being validated
  • param - param passed to the validator

Using this information you can create very complex validation rules.

There is also the parseParam function that check validity of a passed param. It’s not mandatory but it’s a good practice to check param and throw descriptive error for a developer that will be using a given validator. Thanks to that, it’s easier to track errors.

Another attribute is already described method resolveError. It’s just responsible for generating default error message for a given validator. You need to compose an error message and return it.

More complex validators

There are validators like and or or which work in a little bit different way. As a param they take a list of validators. When you examine code of such validators you will notice that we don’t have the isValid() method defined.

Validator.create({
  name: 'and',
  /* ... */
  validate({
    doc,
    name,
    value,
    param: validators
  }) {
    _.each(validators, function(validator) {
      // Get validator.
      const validationFunction = Validators[validator.type];
      // Execute single validator.
      validationFunction({
        doc,
        name,
        value,
        param: validator.param
      });
    });
  }
});

Instead, we have here the validate() method which overrides the default validate() method of a validator. Thanks to that we can run multiple validators passed as a param and decide what to do with a result of such validation. You can learn more about that just by examining code of such validators.

Global configuration

It’s possible to configure some Astronomy behaviors using global configuration object Astro.config. You can access it by importing the Astro object import { Astro } from 'meteor/jagi:astronomy'.

Here is the list of all available configuration options:

  • verbose - true by default - allows turning off deprecation warning
  • resolving - true by default - allows turning off resolving values for performance gain
  • defaults - true by default - allows turning off setting default values on documents fetch

And some example:

import { Astro } from 'meteor/jagi:astronomy';

// Do not log deprection warnings.
Astro.config.verbose = false;

Security

Secured property

Every Astronomy class is “secured” by default and no operations from the client are allowed. It’s similar to removing the insecure package from your Meteor project. In most cases it’s exactly what you want. However, you may turn off security and provide some rules in events. Everything depends on your programming style. Let’s talk about the class level secured property in more details.

The “secured” property

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  // Turn off security for insert, update and remove operations.
  secured: false
});

As you can see, we switched security off for every operation on this class. Now let’s see how to secure only certain operations.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  // Turn off security for update operations.
  secured: {
    update: false
  }
});

In the example above, we turned off security only for update operations.

Now let’s see what will happen if you try insert something.

var user = new User();
user.save(); // Throw error: Inserting from the client is not allowed

Securing application using events

If you set the secured property to false then you have to be very careful about your application security. It will allow anyone to insert, update or remove documents from the client. In such situation, we have to secure a class by defining some security rules in events. Let’s take a look at the example.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  secured: false,
  events: {
    beforeUpdate(e) {
      // Get a document being saved.
      const doc = e.currentTarget;
      // Check if a given user can update a document.
      if (!RolesHelpers.isOwner(doc, Meteor.user())) {
        throw new Meteor.Error(403, 'You are not authorized');
      }
    }
  }
});

As you can see, in the beforeUpdate event, we check if a given user can update a document. For that purpose, we use imaginary helper function RolesHelpers.isOwner() which checks if a user is an owner of a document. In similar way, you have to write any other security rules. It’s very similar to what you can do in the allow/deny rules. Now, let’s see how to access different data.

INSERT:

// Allow/deny rules.
Posts.allow({
  insert(userId, doc) {}
});
// Astronomy.
Post.extend({
  events: {
    beforeInsert(e) {
      const userId = Meteor.userId(); // userId
      const doc = e.currentTarget; // doc
    }
  }
});

UPDATE:

// Allow/deny rules.
Posts.allow({
  update(userId, doc, fieldNames, modifier) {}
});
// Astronomy.
Post.extend({
  events: {
    beforeUpdate(e) {
      const userId = Meteor.userId(); // userId
      const doc = e.currentTarget; // doc
      const fieldNames = doc.getModified(); // fieldNames
      const modifier = doc.getModifier(); // modifier
    }
  }
});

REMOVE:

// Allow/deny rules.
Posts.allow({
  remove(userId, doc) {}
});
// Astronomy.
Post.extend({
  events: {
    beforeRemove(e) {
      const userId = Meteor.userId(); // userId
      const doc = e.currentTarget; // doc
    }
  }
});

As you can see, in Astronomy events you can access all data that is accessible using allow/deny rules. You just define them in a little bit different way. And you are responsible for throwing Meteor.Error with an error message.

Securing application using methods

Securing application using events may not work for every use case. Even MDG is not recommending securing your application using allow/deny rules beside situations where you really know what are you doing and you are very careful. MDG is recommending putting security rules in Meteor methods. That way you can split your application logic into separate methods. It allows you not only defining methods like post/insert, post/update, post/remove but also post/publish, post/rename etc. So you have more control over an operation which you’re performing. Some security rules may work for update operation but not especially for the post/rename operation which in theory is also update operation, but limited to changing only a single field’s value. Let’s see how to define some security rules using Meteor methods.

import { Class } from 'meteor/jagi:astronomy';

const Post = Class.create({
  name: 'Post',
  /* ... */
  secured: true,
  fields: {
    title: String,
    published: {
      type: Boolean,
      default: false
    }
    /* ... */
  },
  meteorMethods: {
    rename(title) {
      const invocation = DDP._CurrentInvocation.get();
      // Check if a given user can rename a post.
      if (!Permissions.canRenamePost(this, invocation.userId)) {
        throw new Meteor.Error(403, 'You can not rename this post');
      }
      this.title = title;
      this.save();
    },
    publish() {
      const invocation = DDP._CurrentInvocation.get();
      // Check if a given user can rename a post.
      if (!Permissions.canPublishPost(this, invocation.userId)) {
        throw new Meteor.Error(403, 'You can not publish this post');
      }
      this.published = true;
      this.save();
    }
  }
});

Now let’s use these methods.

const post = Post.findOne();
post.rename('New post name');
post.publish();

Trusted property

Distinguishing between client and server initiated mutations

Astronomy’s event system automatically provides an extra property on every event called trusted.

The trusted property indicates if the current invocation of your event handler was triggered by a mutation requested by client-side code (should not be trusted and must pass security checks) or server-side code (presumably trusted).

If you omit the insecure package from your app and do not add any allow/deny rules for the Mongo.Collection managed by your Astronomy model, this allows you to implement all access control rules directly in Astronomy event hooks.

IMPORTANT NOTES

  1. Your event hooks must be defined on the server so they can run on the server. They may optionally also be defined on the client.
  2. If client-side code calls save() or remove() on an Astronomy model, both the event hook that runs on the client and the event hook that runs on the server will have e.trusted set to false.
  3. If server-side code calls save() or remove() on an Astronomy model, the event hook will not run on the client, and the event hook that runs on the server will have e.trusted set to true.
  4. If you write your own custom Meteor Method on both the client and the server which internally calls save() or remove() on an Astronomy model, your custom method will be run by Meteor on both the client and the server. This is how Meteor works. When your method is run on the server and calls save() or remove(), the e.trusted property will be true since the call to save() or remove() will have been initiated by the server-side version of your custom Meteor method.

Example implementation on insert

// server/models/car.js
import { Class } from 'meteor/jagi:astronomy';

const Car = Class.create({
  name: 'Car',
  /* ... */
  events: {
    beforeInsert: [
      function checkUserID(e) {
        const car = e.currentTarget;
        if (!e.trusted) {
          // Request to insert came from client-code.
          // Run security checks.
          if (/* Check doesn't pass */) {
            throw new Meteor.Error(
              "not-allowed",
              "You don't have sufficient permissions to create a car."
            );
          }
        }
        else {
          // Request to insert came from server-code.
        }
      },
      function checkSomethingICareAbout(e) {
        // Some other kind of check, possibly using `e.trusted` to determine
        // if we need to run the check
      }
    ]
  }
});

Documentation to be included as part of the API doc of the event’s properties…

trusted: (boolean) false if the event was triggered by client-side code, true otherwise (link to further explanation of this property)

Rate limiting

Astronomy registers several Meteor methods which it uses internally. Like normal Meteor methods, they can be called from any client, and therefore should be safeguarded against flooding your server with method calls.

For that, you can use ddp-rate-limiter. You will have to define rules for the specific names of methods registered by Astronomy and adjust the message and time interval limits according to your specific application requirements.

Methods registered by Astronomy

  • /Astronomy/execute
  • /Astronomy/insert
  • /Astronomy/remove
  • /Astronomy/update
  • /Astronomy/upsert
  • /Astronomy/validate

Methods registered for softremove-behavior

  • /Astronomy/softRemove
  • /Astronomy/softRestore

An example rule with ddp-rate-limiter

// /server/lib/ratelimit.js
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';

// Define method names you wish to create a rule for
const ASTRONOMY_METHODS = [
  '/Astronomy/execute',
  '/Astronomy/insert',
  '/Astronomy/remove',
  '/Astronomy/update',
  '/Astronomy/upsert',
  '/Astronomy/validate'
];

// Add new rule
DDPRateLimiter.addRule({
  name(name) {
    // Match methods registered by Astronomy
    return _.contains(ASTRONOMY_METHODS, name);
  },
  // Limit per DDP connection
  connectionId() { return true; }
}, 5, 1000); // Allow 5 messages per second

Building with Astronomy

The usefulness of Astronomy comes from its ability to help maintain the integrity of your data and also to organize the code related to your model layer. It can serve as basic documentation of your collections and the properties that documents in those collections have.

Before deep-diving into the API docs, let’s take a look at what a full-fledged model in Astronomy looks like to demonstrate most of the capabilities of Astronomy in one place and show how they can work together.

Let’s assume we are building a chat-type app that has a central model called “Conversations”. Here’s what that model might look like in <your app>/lib/models/conversation.js.

// Flag for determining who (by default) can see what conversations.
const Visibility = Enum.create({
  name: 'Visibility',
  options: {
    PUBLIC: 0,
    PRIVATE: 1,
    INVITE_ONLY: 2
  }
});

// Sub-model for keeping track of a user
// that is participating in a conversation.
const Participant = Class.create({
  name: 'Participant',
  behaviors: ['timestamp'],
  fields: {
    userId: {
      type: String
    },
    status: {
      type: String
    }
  }
});

// Helper function for making sure that the
// request is coming from the server directly,
// or that it is coming from an authenticated user.
function mustBeLoggedIn(e) {
  if (!e.trusted && !Meteor.userId()) {
    // Anonymous client is trying to create a conversation.
    throw new Meteor.Error(
      'must-be-logged-in',
      'You must have an account to create a new Conversation.'
    );
  }
}

// A Conversation represents one or more users who are talking together.
// This model contains all the "metadata" about the conversation, while
// actual messages are stored in the Message collection.
const Conversation = Class.create({
  name: 'Conversation',
  collection: new Mongo.Collection('conversations'),
  behaviors: ['timestamp'],
  fields: {
    /**
     * The userId of the user who created the conversation, or 'SYSTEM'
     * if created by the app.
     **/
    ownerId: {
      type: String,
      default () {
        return Meteor.userId() || 'SYSTEM';
      },
      immutable: true
    },
    /**
     * A catch-all property to store custom extra info per conversation.
     **/
    metadata: {
      type: Object,
      default () {
        return {};
      }
    },
    /**
     * Basic access-control flag.
     **/
    visibility: {
      type: Visibility,
      default: Visibility.PUBLIC
    },
    /**
     * Users who are interested in this conversation.
     **/
    participants: {
      type: [Participant],
      default () {
        if (Meteor.userId()) {
          return [
            new Participant({
              userId: Meteor.userId()
            })
          ];
        }
        else {
          return [];
        }
      }
    }
  },
  indexes: {
    // make sure we can quickly find by ownerId
    ownerIdIndex: {
      fields: {
        ownerId: 1
      }
    },
    // make sure we can quickly find by visibility
    visibilityIndex: {
      fields: {
        visibility: 1
      }
    }
  },
  events: {
    beforeInsert: mustBeLoggedIn,
    beforeUpdate: [
      mustBeLoggedIn,
      function mustBeAParticipant(e) {
        if (e.trusted) {
          return; // allow all updates made by the server
        }

        var conversation = e.currentTarget;
        var userId = Meteor.userId();
        var participant = _.find(
          conversation.participants,
          function(participant) {
            return participant.userId === userId;
          }
        );

        if (!participant) {
          throw new Meteor.Error(
            'must-be-participant',
            'You must have already joined the conversation to do that.'
          );
        }
      },
      function visibilityCanOnlyBeSetByOwner(e) {
        if (e.trusted) {
          return; // allow all updates made by the server
        }

        var conversation = e.currentTarget;
        var modifiedFields = conversation.getModified();

        if (
          _.contains(modifiedFields, 'visibility') &&
          Meteor.userId() !== conversation.ownerId
        ) {
          throw new Meteor.Error(
            'must-be-owner',
            'You cannot change the visibility of this conversation ' +
            'since you are not its owner.'
          );
        }
      }
    ],
    beforeRemove: function disallowRemoveConversation(e) {
      throw new Meteor.Error(
        'cannot-remove-conversation',
        'Conversations may not be deleted.'
      );
    }
  },
  helpers: {
    /**
     * Get all the messages for this conversation, sorted by most recent.
     */
    messages() {
      // assuming we have another Astronomy Model for our conversation's messages
      return Message.find({
        conversationId: this._id
      }, {
        sort: {
          createdAt: 1
        }
      });
    },
    /**
     * Convenience helper for sending a message to everyone in this conversation.
     **/
    send(content, callback) {
      var message = new Message({
        userId: Meteor.userId(),
        conversationId: this._id,
        content: content
      });
      return message.save(callback);
    },
    /**
     * Adds a user to this conversation's participants.
     **/
    join(userId, callback) {
      if (_.isFunction(userId)) {
        callback = userId;
        userId = null;
      }

      userId = userId || Meteor.userId();

      if (!userId) {
        return callback && callback(
          new Error('Invalid user, cannot join conversation.')
        );
      }

      return Conversation.update(this._id, {
        $push: {
          participants: new Participant({
            userId: userId
          })
        }
      }, callback);
    }
  }
});

Creating a conversation

var conversation = new Conversation();
conversation.save();

Sending a message to everyone in this conversation

var conversation = Conversation.findOne(<a conversation._id>);
conversation.send("I think I'm getting the hang of this...");

Joining a conversation as a logged in user

var conversation = Conversation.findOne(<a conversation._id>);
conversation.join(function(err){ ... });

Server adds a user to a conversation

var conversation = Conversation.findOne(<a conversation._id>);
conversation.join(<a user._id>);

Get all the messages for a conversation

var conversation = Conversation.findOne(<a conversation._id>);
conversation.messages();

Notes

This gets you pretty far along. In slightly more advanced code, this is what would change:

  1. The mustBeLoggedIn function would be placed into an namespace like Auth.mustBeLoggedIn so that it could be re-used across multiple models and unit tested.
  2. The internals of the methods like send and join would be placed into separate utility functions so that they could be easily unit tested and the methods attached to the model would simply reference them.

Behaviors

A behavior allows extending a class schema with some extra functionality. Most of the time the functionality is a feature that is repeated over and over again for many classes. If you experience such situation, it may be a good idea to create custom behavior.

You can add behavior to the class in the following way.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  behaviors: {
    behaviorName: {
      /* Behavior options */
    }
  }
});

You may also want to add several behaviors of the same time to the same class. Here is how to do it.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  name: 'User',
  /* ... */
  behaviors: {
    behaviorName: [{
      /* 1. behavior options */
    }, {
      /* 2. behavior options */
    }]
  }
});

Let’s talk about predefined behaviors.

Timestamp

You can add the timestamp behavior to your project by executing the following command in your Meteor project directory.

meteor add jagi:astronomy-timestamp-behavior

The timestamp behavior adds two fields that store information about document’s creation and update dates. The behavior comes with following options. Options names are self explanatory.


behaviors: {
  timestamp: {
    hasCreatedField: true,
    createdFieldName: 'createdAt',
    hasUpdatedField: true,
    updatedFieldName: 'updatedAt'
  }
}

Let’s take a look at the behavior usage.

var post = new Post();
post.save();

post.createdAt; // A document creation date.

/* ... */

post.save();
post.updatedAt; // A document modification date.

Slug

NOTICE: This description is for v.3.0.0 of the slug behavior.

You can add the slug behavior to your project by executing the following command in your Meteor project directory.

meteor add jagi:astronomy-slug-behavior

The slug behavior adds a slug field for storing an URL friendly value of a chosen field. The slug field can be used in the routing for generating URLs http://localhost:3000/post/to-jest-test-polskich-znakow-aszclonz. The behavior comes with following options.

behaviors: {
  slug: {
    // The field name from which a slug will be created.
    fieldName: null,
    // The helper name that generates a value for the slug-ification process.
    helperName: null,
    // The field name where a slug will be stored.
    slugFieldName: 'slug',
    // A flag indicating if we can update a slug.
    canUpdate: true,
    // A flag indicating if a slug is unique.
    unique: true,
    // A separator used for generating a slug.
    separator: '-'
  }
}

Let’s take a look at the behavior usage.

var post = new Post();
post.title = 'To jest test polskich znaków ąśźćłóńż';
post.save();

post.slug; // "to-jest-test-polskich-znakow-aszclonz"

The fieldName vs helperName

There are two possible ways of generating a slug: from a value of the field or a the value returned by the helper.

If we want to generate a slug from a single field then we have to provide the fieldName option.

If we want to generate a slug from multiple fields or from a manually generated value then we have to provide the helperName option.

NOTICE: You can’t use both ways of generating slug. You have to choose between the fieldName option or the helperName option.

Let’s take a look at the example of generating a slug using a helper.

import { Class } from 'meteor/jagi:astronomy';

const User = Class.create({
  /* ... */
  helpers: {
    fullName() {
      // Slug will be generated from the returned value.
      return this.firstName + ' ' + this.lastName;
    }
  },
  behaviors: {
    slug: {
      // You can't use the "fieldName option when using the "helperName" option.
      // fieldName: 'lastName',

      // The helper name that generates a value for the slug-ification process.
      helperName: 'fullName'
    }
  }
});

Softremove

You can add the softremove behavior to your project by executing the following command in your Meteor project directory.

meteor add jagi:astronomy-softremove-behavior

The softremove behavior let’s you remove a document without deleting it from the collection. Instead it’s marked as removed. Removed documents can be excluded from displaying in a template. The behavior comes with following options.

behaviors: {
  softremove: {
    // The field name with a flag for marking a document as removed.
    removedFieldName: 'removed',
    // A flag indicating if a "removedAt" field should be present in a document.
    hasRemovedAtField: true,
    // The field name storing the removal date.
    removedAtFieldName: 'removedAt'
  }
}

Let’s take a look at the behavior usage.

var user = User.findOne();
// Sets the "removed" flag to true and saves it into the collection
user.softRemove();

Ok, but how to exclude removed document from being fetched. You have to use the find() or findOne() method defined on the class level.

// Get only not removed users.
var onlyNotRemovedUsers = User.find();
// Get all users.
var allUsers = User.find({}, {
  disableEvents: true
});

NOTICE: In the first line, we call the find() method from the User class and in the second line from the Users collection. The slug behavior uses the beforeFind event to modify selector that will cause fetching only non-removed documents.

You can also restore a soft removed document.

var user = User.findOne();
// Sets the "removed" flag to true and saves it into the collection
user.softRemove();

/* ... */

// Later, having the same reference to the document.
user.softRestore();

Advanced usage

Custom types

In Astronomy you can create custom types. An example of such custom type is MongoObjectID. Let’s take a look at the example of creating this type.

import Type from '../type.js';
import Validators from '../../validators/validators.js';
import { Mongo } from 'meteor/mongo';

Type.create({
  name: 'MongoObjectID',
  class: Mongo.ObjectID,
  validate(args) {
    Validators.mongoObjectID(args);
  }
});

As you can see, we have here three properties. The first one is a name, which is mandatory. The second one is a class or constructor, that is used to create instances of our type. In this situation, it’s just Mongo.ObjectID. But it could be also String or any custom class/constructor that you created. The last property is the validate() function which validates value stored in a field with our type used.

Now, let’s see how to use our type in a schema.

import { Class } from 'meteor/jagi:astronomy';
import { Mongo } from 'meteor/mongo';

const Post = Class.create({
  name: 'Post',
  /* ... */
  fields: {
    threadId: {
      type: Mongo.ObjectID
    }
  }
});

We only pass our type constructor as the field’s type. That way Astronomy will know where to look for a type definition and what validation it should perform.

Writing behaviors

IN PROGRESS

Writing modules

IN PROGRESS