danmacko:publish-relations

v3.2.0Published 2 days ago

publish-relations

Maintained fork of cottz:publish-relations by lfades (MIT). Includes bug fixes and improvements over the original — see the releases.

Edit your documents before sending without too much stress. provides a number of methods to easily manipulate data using internally observe and observeChanges in the server

Installation

$ meteor add danmacko:publish-relations

Compatibility

MinimumMeteor 2.3 — enforced by api.versionsFrom in package.js
Tested onMeteor 2.15 (both test layers, see Testing)
Meteor 3Not yet supported

Quick Start

Assuming we have the following collections

1// Authors
2{
3  _id: 'someAuthorId',
4  name: 'Luis',
5  profile: 'someProfileId',
6  bio: 'I am a very good and happy author',
7  interests: ['writing', 'reading', 'others']
8}
9
10// Books
11{
12  _id: 'someBookId',
13  authorId: 'someAuthorId',
14  name: 'meteor for dummies'
15}
16
17// Comments
18{
19  _id: 'someCommentId',
20  bookId: 'someBookId',
21  text: 'This book is better than meteor for pros :O'
22}

I want publish the autor with his books and comments of the books

1import PublishRelations from 'meteor/danmacko:publish-relations';
2
3PublishRelations('author', function (authorId) {
4  this.cursor(Authors.find(authorId), function (id, doc) {
5    this.cursor(Books.find({authorId: id}), function (id, doc) {
6      this.cursor(Comments.find({bookId: id}));
7    });
8  });
9
10  return this.ready();
11});

Note: The above code is very nice and works correctly, but I recommend that you read the Performance Notes

Main API

to use the following methods you should use PublishRelations instead of Meteor.publish

this.cursor (cursor, collection, callbacks(id, doc, changed))

publishes a cursor, collection is not required

  • collection is the collection where the cursor will be sent. if not sent, is the default cursor collection name
  • callbacks is an object with 3 functions (added, changed, removed) or a function that is called when it is added and changed and receive in third parameter a Boolean value that indicates if is changed
  • If you send callbacks you can use all the methods again and you can edit the document directly (doc.property = 'some') or send it in the return.

Note: when a document changes (update) doc contains only the changes, not the whole document.

That note cuts both ways, and the writing side is the easy one to miss. Editing a field that is not in the update adds it to the update, so the client is told that field changed — and whatever it already held is overwritten:

1this.cursor(Meteor.users.find(), function (id, doc, changed) {
2  // BUG: on any update that does not touch roomId, doc.roomId is undefined, so
3  // this puts 'lobby' into the update and the client loses the real roomId
4  doc.roomId = doc.roomId || 'lobby';
5});

Guard it on the add, or on the field really being part of the update:

1this.cursor(Meteor.users.find(), function (id, doc, changed) {
2  if (!changed || 'roomId' in doc)
3    doc.roomId = doc.roomId || 'lobby';
4});

'roomId' in doc also holds when the field was cleared — a removed field arrives in the update with the value undefined — so the default still applies then.

Defaults like this are usually better applied where the data is read, since the publication has to describe a change and not a state.

this.join (Collection, options, name)

It allows you to collect a lot of _ids and then make a single query, only Collection is required.

  • Collection is the Mongo Collection to be used
  • options the options parameter in a Collection.find
  • name the name of a different collection to receive documents there

After creating an instance of this.join you can do the following

1const comments = this.join(Comments, {});
2// default query is {_id: _id} or {_id: {$in: _ids}}
3// if you need to use another field use selector
4comments.selector = function (_ids) {
5  // _ids is {$in: _ids} or a single _id
6  return {bookId: _ids};
7};
8// Adds a new id to the query
9comments.push(id);
10comments.push(id2, id3, id4);
11// Sends the query to the client, after sending the query each new push()
12// send a new query, you do not have to worry about reactivity or
13// performance with this method
14comments.send();

Why use this and not this.cursor? because they are just 2 queries

1const comments = this.join(Comments, {});
2comments.selector = _ids => {bookId: _ids};
3
4this.cursor(Books.find(), function (id, doc) {
5  comments.push(id);
6});
7
8comments.send();

this.observe / this.observeChanges (cursor, callbacks)

observe or observe changes in a cursor without sending anything to the client, callbacks are the same as those used by meteor

Nonreactive API

The following methods work much like their peers but they are not reactive

this.cursorNonreactive (cursor, collection, callback)

It has 2 differences with this.cursor

  • callback is only a function that executes when a document is added
  • you can only use non-reactive methods within the callback

this.joinNonreactive (Collection, options, name)

Is exactly the same as this.join but non reactive

Performance Notes

  • all methods returns an object with the stop() method
  • all cursors are stopped when the publication stop
  • when the parent cursor is stopped or a document with cursors is removed all related cursors are stopped
  • all cursors use basic observeChanges as meteor does by default, performance does not come down
  • if when the callback is re-executes not called again some method (within an If for example), the method continues to run normally, if you re-call method (because the selector is now different) the previous method is replaced with the new
1// For example we have a collection users and each user has a roomId
2// we want to publish the users and their rooms
3this.cursor(Meteor.users.find(), function (id, doc) {
4  // this function is executed on added/changed
5  this.cursor(Rooms.find({_id: doc.roomId}));
6});
7// the previous cursor is good but has a bug, when an user is changed we can't make sure
8// that the roomId is changed and 'doc' only comes with the changes, so roomId is undefined
9// and our Rooms cursor no longer work anymore
10
11// to fix the above problem we need to check the roomId
12this.cursor(Meteor.users.find(), function (id, doc) {
13  if (doc.roomId)
14    this.cursor(Rooms.find({_id: doc.roomId}));
15});
16// or we can use an object with 'added' instead of a function
17// this way is better than the above if we are sure that roomId is not going to change
18this.cursor(Meteor.users.find(), {
19  added: function (id, doc) {
20    this.cursor(Rooms.find({_id: doc.roomId}));
21  }
22});
  • As I said in Quick Start you can do this
1this.cursor(Authors.find(authorId), function (id, doc) {
2  this.cursor(Books.find({authorId: id}), function (id, doc) {
3    this.cursor(Comments.find({bookId: id}));
4  });
5});

but you will find that the publication is becoming increasingly slow, suppose you have 10 books for a given author and every book has 100 reviews, with this method would make the following queries: 1 author + 1 books + 10 comments = 12 queries, for each book found a query is made to find comments which creates a performance issue and publication could take seconds

The solution is to use this.join to join all the comments and send them in a single query, passing from 12 queries to 3 queries for mongo

1const comments = this.join(Comments);
2comments.selector = _ids => {bookId: _ids};
3
4this.cursor(Authors.find(authorId), function (id, doc) {
5  // We not have to worry about the books cursor because we only have one author
6  this.cursor(Books.find({authorId: id}), function (id, doc) {
7    comments.push(id);
8  });
9});
10
11comments.send();
  • publications are completed as usual
1// you can do this to finish writing your publication
2this.ready();
3return this.ready();
4return [];
5return [cursor1, cursor2, cursor3];

Limitations

One publication sending the same collection twice

Everything a publication sends goes out under a single subscription handle, and Meteor tracks a published document per handle, not per publisher. So when a join and a cursor of the same publication both send documents of one collection, the first removed from either takes the document away from the client even though the other still matches it — and it comes back only when that other cursor next restarts.

Keep the join's membership a superset of what the cursors publish and it cannot happen, because a retraction then only fires once nothing wants the document any more:

1// Rooms are sent twice here: by the join, and by the nested cursor.
2this.cursor(Meteor.users.find(), function (id, doc) {
3  if (doc.roomId) {
4    rooms.push(doc.roomId);                       // <- keeps the two in step
5    this.cursor(Rooms.find({_id: doc.roomId}), function (id, room) {
6      owners.push(room.ownerId);
7    });
8  }
9});

That is worth checking whenever a publication has both a join and a cursor on one collection: for every such cursor there should be a push into the matching join. Where that is not possible, either give one of them its own name (this.cursor(cursor, 'roomsLookup', callbacks), which sends it to a separate client collection) or use this.observe, which runs callbacks without sending anything at all.

In development the package warns when this bites — once per collection, when a cursor tries to update a document a join has already retracted. Two caveats: the warning reports the consequence, so a document that is retracted and then never changes again produces no warning at all; and it is gated on Meteor.isDevelopment, so a staging build running in production mode stays silent about it.

Testing

The suite has two layers. Run both with one command:

./test.sh          # unit layer only — fast, no side effects
./test.sh --full   # both layers (boots a Meteor test server on $PORT, default 3199)

Unit layer (tests/unit/) loads the server modules into a vm with Meteor stubbed out, so it runs in plain node in milliseconds — no Meteor, no MongoDB. That gives deterministic control over things a DDP client cannot see: what sits in the deferred restart queue, what happens when a release lands in the same tick as a push, how a subscription that is already deactivated behaves, and the overlap guards for two cursors publishing the same collection.

node tests/unit/run.js

The test scripts require Node 14.18+ (they import builtins with the node: prefix). ./test.sh checks for that and falls back to the Node bundled with the Meteor tool when the system one is older or missing.

Tinytest layer (tests/*.js) runs against a real Meteor server, a real MongoDB and a real DDP client. It owns what only the real stack can show: DDP message order, and the observer lifecycle — leaks, replacement on restart, and teardown. Observer counts are read from MongoInternals, because a leaked observer is invisible from the client side.

Every test in this package is server-side, so no browser is needed. Instead of the browser reporter (or test-in-console, which drives headless Chrome via puppeteer), tests/headless-driver.js speaks raw DDP to the test server and calls the tinytest/run method directly:

meteor test-packages --release METEOR@2.15 --port 3199 ./   # one shell
node tests/headless-driver.js                               # another

It exits non-zero when a test fails, so it can gate CI.

Tinytest has no built-in timeouts, so the tests use a deadline() helper — a regression must report as a failure, not hang the suite.