matb33:collection-hooks

v2.1.0-beta.4Published 3 weeks ago

Meteor Collection Hooks

Test suite Code lint CodeQL Analysis

Extends Mongo.Collection with before/after hooks for insert, update, remove, upsert, find, and findOne. Meteor 2.16+ & 3.x compatible with async hook support.

Works across client, server or a mix. Also works when a client initiates a collection method and the server runs the hook, all while respecting the collection validators (allow/deny).

Please refer to History.md for a summary of recent changes.

Getting Started

Installation:

meteor add matb33:collection-hooks

Meteor 3 Compatibility & Async Hooks

Meteor Version Support: 2.16+ through 3.1+

Important Behavioral Changes in Meteor 3:

Async Hooks Support

As of v2.0.0, most hooks support async functions:

1// ✅ Async hooks work for these operations
2collection.before.insert(async function(userId, doc) {
3  await validateDoc(doc);
4});
5
6collection.after.update(async function(userId, doc, fieldNames, modifier, options) {
7  await notifyExternalService(doc);
8});

Find Hooks Limitations

Due to Meteor 3's synchronous find() method, find hooks have specific limitations:

1// ✅ WORKS: Sync before.find hooks
2collection.before.find(function(userId, selector, options) {
3  selector.deletedAt = { $exists: false }; // Modify selector
4});
5
6// ❌ THROWS ERROR: Async before.find hooks  
7collection.before.find(async function(userId, selector, options) {
8  // This will throw: "Cannot use async function as before.find hook"
9});
10
11// ✅ WORKS: after.find hooks (sync and async)
12collection.after.find(async function(userId, selector, options, cursor) {
13  await logFindOperation(selector);
14});

Hook Trigger Conditions

findOne Hooks:

1await collection.findOneAsync({})  // ✅ Triggers hooks
2collection.findOne({})             // ❌ No hooks triggered

find Hooks:

1// ✅ These trigger find hooks:
2const cursor = collection.find({});
3await cursor.fetchAsync();    // ✅ Hooks fire
4await cursor.countAsync();    // ✅ Hooks fire
5await cursor.forEachAsync();  // ✅ Hooks fire
6
7// ❌ These DON'T trigger find hooks:
8collection.find({}).fetch();  // ❌ No hooks
9collection.find({}).count();  // ❌ No hooks

.before.insert(userId, doc)

Fired before the doc is inserted.

Allows you to modify doc as needed, or run additional functionality

  • this.transform() obtains transformed version of document, if a transform was

defined.

1import { Mongo } from 'meteor/mongo';
2const test = new Mongo.Collection("test");
3
4test.before.insert(function (userId, doc) {
5  doc.createdAt = Date.now();
6});

.before.update(userId, doc, fieldNames, modifier, options)

Fired before the doc is updated.

Allows you to to change the modifier as needed, or run additional functionality.

  • this.transform() obtains transformed version of document, if a transform was

defined.

1test.before.update(function (userId, doc, fieldNames, modifier, options) {
2  modifier.$set = modifier.$set || {};
3  modifier.$set.modifiedAt = Date.now();
4});

Important:

  1. Note that we are changing modifier, and not doc.

Changing doc won't have any effect as the document is a copy and is not what ultimately gets sent down to the underlying update method.

  1. When triggering a single update targeting multiple documents using the option multi: true (see Meteor documentation), the before.update hook is called once per document about to be updated, but the collection update called afterwards remains a single update (targetting multiple documents) with a single modifier. Hence it is not possible at the time to use before.update to create a specific modifier for each targeted document.

.before.remove(userId, doc)

Fired just before the doc is removed.

Allows you to to affect your system while the document is still in existence -- useful for maintaining system integrity, such as cascading deletes.

  • this.transform() obtains transformed version of document, if a transform was

defined.

1test.before.remove(function (userId, doc) {
2  // ...
3});

.before.upsert(userId, selector, modifier, options)

Fired before the doc is upserted.

Allows you to to change the modifier as needed, or run additional functionality.

1test.before.upsert(function (userId, selector, modifier, options) {
2  modifier.$set = modifier.$set || {};
3  modifier.$set.modifiedAt = Date.now();
4});

Note that calling upsert will always fire .before.upsert hooks, but will call either .after.insert or .after.update hooks depending on the outcome of the upsert operation. There is no such thing as a .after.upsert hook at this time.


.after.insert(userId, doc)

Fired after the doc was inserted.

Allows you to run post-insert tasks, such as sending notifications of new document insertions.

  • this.transform() obtains transformed version of document, if a transform was

defined;

  • this._id holds the newly inserted _id if available.
1test.after.insert(function (userId, doc) {
2  // ...
3});

.after.update(userId, doc, fieldNames, modifier, options)

Fired after the doc was updated.

Allows you to to run post-update tasks, potentially comparing the previous and new documents to take further action.

  • this.previous contains the document before it was updated.
    • The optional fetchPrevious option, when set to false, will not fetch documents before running the hooks. this.previous will then not be available. The default behavior is to fetch the documents.
  • this.transform() obtains transformed version of document, if a transform was defined. Note that this function accepts an optional parameter to specify the document to transform — useful to transform previous: this.transform(this.previous).
1test.after.update(function (userId, doc, fieldNames, modifier, options) {
2  // ...
3}, {fetchPrevious: true/false});

Important: If you have multiple hooks defined, and at least one of them does not specify fetchPrevious: false, then the documents will be fetched and provided as this.previous to all hook callbacks. All after-update hooks for the same collection must have fetchPrevious: false set in order to effectively disable the pre-fetching of documents.

It is instead recommended to use the collection-wide options (e.g. MyCollection.hookOptions.after.update = {fetchPrevious: false};).

This hook will always be called with the new documents; even if the updated document gets modified in a way were it would normally not be able to be found because of before.find hooks (see https://github.com/Meteor-Community-Packages/meteor-collection-hooks/pull/297).


.after.remove(userId, doc)

Fired after the doc was removed.

doc contains a copy of the document before it was removed.

Allows you to run post-removal tasks that don't necessarily depend on the document being found in the database (external service clean-up for instance).

  • this.transform() obtains transformed version of document, if a transform was

defined.

1test.after.remove(function (userId, doc) {
2  // ...
3});

.before.find(userId, selector, options)

Fired before a find query. Meteor 3 Limitation: Cannot be async.

Allows you to adjust selector/options on-the-fly.

1test.before.find(function (userId, selector, options) {
2  // ✅ Sync operations only
3  selector.deletedAt = { $exists: false };
4});
5
6// ❌ This will throw an error:
7test.before.find(async function (userId, selector, options) {
8  // Error: "Cannot use async function as before.find hook"
9});

Important:


.after.find(userId, selector, options, cursor)

Fired after a find query when using cursor async methods.

Allows you to act on a given find query. Both sync and async functions are supported.

1// ✅ Sync after.find
2test.after.find(function (userId, selector, options, cursor) {
3  logOperation(selector);
4});
5
6// ✅ Async after.find  
7test.after.find(async function (userId, selector, options, cursor) {
8  await logToExternalService(selector);
9});

Triggers only on cursor async methods:

1const cursor = collection.find({});
2await cursor.fetchAsync();    // ✅ Triggers after.find
3await cursor.countAsync();    // ✅ Triggers after.find
4cursor.fetch();               // ❌ No hooks triggered

.before.findOne(userId, selector, options)

Fired before a findOne query. Supports async functions.

1// ✅ Sync before.findOne
2test.before.findOne(function (userId, selector, options) {
3  selector.status = 'active';
4});
5
6// ✅ Async before.findOne
7test.before.findOne(async function (userId, selector, options) {
8  await enrichSelector(selector);
9  // Return false to abort the operation
10  return false;
11});

Only triggers on async methods:

1await collection.findOneAsync({})  // ✅ Triggers hooks
2collection.findOne({})             // ❌ No hooks triggered

.after.findOne(userId, selector, options, doc)

Fired after a findOne query. Supports async functions.

1// ✅ Async after.findOne
2test.after.findOne(async function (userId, selector, options, doc) {
3  await processDocument(doc);
4});

Only triggers on async methods:

1await collection.findOneAsync({})  // ✅ Triggers hooks
2collection.findOne({})             // ❌ No hooks triggered

Direct access (circumventing hooks)

All compatible methods have a direct version that circumvent any defined hooks. For example:

1collection.direct.insert({_id: "test", test: 1});
2collection.direct.insertAsync({_id: "test", test: 1});
3collection.direct.upsert({_id: "test", test: 1});
4collection.direct.upsertAsync({_id: "test", test: 1});
5collection.direct.update({_id: "test"}, {$set: {test: 1}});
6collection.direct.updateAsync({_id: "test"}, {$set: {test: 1}});
7collection.direct.find({test: 1});
8collection.direct.findOne({test: 1});
9collection.direct.findOneAsync({test: 1});
10collection.direct.remove({_id: "test"});
11collection.direct.removeAsync({_id: "test"});

Default options

As of version 0.7.0, options can be passed to hook definitions. Default options can be specified globally and on a per-collection basis for all or some hooks, with more specific ones having higher specificity.

Examples (in order of least specific to most specific):

1import { CollectionHooks } from 'meteor/matb33:collection-hooks';
2
3CollectionHooks.defaults.all.all = {exampleOption: 1};
4
5CollectionHooks.defaults.before.all = {exampleOption: 2};
6CollectionHooks.defaults.after.all = {exampleOption: 3};
7
8CollectionHooks.defaults.all.update = {exampleOption: 4};
9CollectionHooks.defaults.all.remove = {exampleOption: 5};
10
11CollectionHooks.defaults.before.insert = {exampleOption: 6};
12CollectionHooks.defaults.after.remove = {exampleOption: 7};

Similarly, collection-wide options can be defined (these have a higher specificity than the global defaults from above):

1import { Mongo } from 'meteor/mongo';
2const testCollection = new Mongo.Collection("test");
3
4testCollection.hookOptions.all.all = {exampleOption: 1};
5
6testCollection.hookOptions.before.all = {exampleOption: 2};
7testCollection.hookOptions.after.all = {exampleOption: 3};
8
9testCollection.hookOptions.all.update = {exampleOption: 4};
10testCollection.hookOptions.all.remove = {exampleOption: 5};
11
12testCollection.hookOptions.before.insert = {exampleOption: 6};
13testCollection.hookOptions.after.remove = {exampleOption: 7};

Currently (as of 0.7.0), only fetchPrevious is implemented as an option, and is only relevant to after-update hooks.


Additional notes

  • Returning false in any before hook will prevent the underlying method (and

subsequent after hooks) from executing. Note that all before hooks will still continue to run even if the first hook returns false.

  • ~~If you wish to make userId available to a find query in a publish

function, try the technique detailed in this comment~~ userId is available to find and findOne queries that were invoked within a publish function.

  • All hook callbacks have this.originalMethod available to them (the underlying

method) as well as this.context, the equivalent of this to the underlying method. Additionally, this.args contain the original arguments passed to the method and can be modified by reference (for example, modifying a selector in a before hook so that the underlying method uses this new selector).

  • It is quite normal for userId to sometimes be unavailable to hook callbacks

in some circumstances. For example, if an update is fired from the server with no user context, the server certainly won't be able to provide any particular userId.

  • You can define a defaultUserId in case you want to pass an userId to the hooks but there is no context. For instance if you are executing and API endpoint where the userId is derived from a token. Just assign the userId to CollectionHooks.defaultUserId. It will be overriden by the userId of the context if it exists.

  • If, like me, you transform Meteor.users through a round-about way involving

find and findOne, then you won't be able to use this.transform(). Instead, grab the transformed user with findOne.

  • When adding a hook, a handler object is returned with these methods:

    • remove(): will remove that particular hook;
    • replace(callback, options): will replace the hook callback and options.
  • If your hook is defined in common code (both server and client), it will run

twice: once on the server and once on the client. If your intention is for the hook to run only once, make sure the hook is defined somewhere where only either the client or the server reads it. When in doubt, define your hooks on the server.

  • Both update and remove internally make use of find, so be aware that

find/findOne hooks can fire for those methods.

  • find hooks are also fired when fetching documents for update, upsert and remove hooks.

  • Meteor 3 Behavior: Find hooks only trigger on async cursor methods (fetchAsync(), countAsync(), etc.). Sync methods (fetch(), count()) do not trigger hooks.

  • findOne Behavior: findOne hooks only trigger on findOneAsync(). The sync findOne() method does not trigger hooks in Meteor 3.

  • before.find hooks cannot be async and will throw an error if an async function is provided.

  • If using the direct version to bypass a hook, any mongo operations done within nested

callbacks of the direct operation will also by default run as direct. You can use the following line in a nested callback before the operation to unset the direct setting: CollectionHooks.directEnv = new Meteor.EnvironmentVariable(false)


Maintainers

Maintained by Meteor Community Packages and in particular by:

Contributors