36

EDIT: this question, some of the answers, and some of the comments, contain a lot of misinformation. See how Meteor collections, publications and subscriptions work for an accurate understanding of publishing and subscribing to multiple subsets of the same server collection.


How does one go about publishing different subsets (or "views") of a single collection on the server as multiple collections on the client?

Here is some pseudo-code to help illustrate my question:

items collection on the server

Assume that I have an items collection on the server with millions of records. Let's also assume that:

  1. 50 records have the enabled property set to true, and;
  2. 100 records have the processed property set to true.

All others are set to false.

items:
{
    "_id": "uniqueid1",
    "title": "item #1",
    "enabled": false,
    "processed": false
},
{
    "_id": "uniqueid2",
    "title": "item #2",
    "enabled": false,
    "processed": true
},
...
{
    "_id": "uniqueid458734958",
    "title": "item #458734958",
    "enabled": true,
    "processed": true
}

Server code

Let's publish two "views" of the same server collection. One will send down a cursor with 50 records, and the other will send down a cursor with 100 records. There are over 458 million records in this fictitious server-side database, and the client does not need to know about all of those (in fact, sending them all down would probably take several hours in this example):

var Items = new Meteor.Collection("items");

Meteor.publish("enabled_items", function () {
    // Only 50 "Items" have enabled set to true
    return Items.find({enabled: true});
});

Meteor.publish("processed_items", function () {
    // Only 100 "Items" have processed set to true
    return Items.find({processed: true});
});

Client code

In order to support the latency compensation technique, we are forced to declare a single collection Items on the client. It should become apparent where the flaw is: how does one differentiate between Items for enabled_items and Items for processed_items?

var Items = new Meteor.Collection("items");

Meteor.subscribe("enabled_items", function () {
    // This will output 50, fine
    console.log(Items.find().count());
});

Meteor.subscribe("processed_items", function () {
    // This will also output 50, since we have no choice but to use
    // the same "Items" collection.
    console.log(Items.find().count());
});

My current solution involves monkey-patching _publishCursor to allow the subscription name to be used instead of the collection name. But that won't do any latency compensation. Every write has to round-trip to the server:

// On the client:
var EnabledItems = new Meteor.Collection("enabled_items");
var ProcessedItems = new Meteor.Collection("processed_items");

With the monkey-patch in place, this will work. But go into Offline mode and changes won't appear on the client right away -- we'll need to be connected to the server to see changes.

What's the correct approach?


EDIT: I just revisited this thread and I realize that, as it stands, my question and answers and plethora of comments carry a lot of misinformation.

What it comes down to is that I misunderstood the publish-subscribe relationship. I thought that when you published a cursor, it would land on the client as a separate collection from other published cursors that originated from the same server collection. This is simply not how it works. The idea is that both the client and server have the same collections, but it's what is in the collections that differs. The pub-sub contracts negotiate which documents end up on the client. Tom's answer is technically correct, but was missing a few details to turn my assumptions around. I answered a similar question to mine in another SO thread based on Tom's explanation, but keeping in mind my original misunderstanding of Meteor's pub-sub: Meteor publish/subscribe strategies for unique client-side collections

Hope this helps those who run across this thread and come away more confused than anything!

Community
  • 1
  • 1
matb33
  • 2,820
  • 1
  • 19
  • 28

3 Answers3

34

Could you not just use the same query client-side when you want to look at the items?

In a lib directory:

enabledItems = function() {
  return Items.find({enabled: true});
}
processedItems = function() {
  return Items.find({processed: true});
}

On the server:

Meteor.publish('enabled_items', function() {
  return enabledItems();
});
Meteor.publish('processed_items', function() {
  return processedItems();
});

On the client

Meteor.subscribe('enabled_items');
Meteor.subscribe('processed_items');

Template.enabledItems.items = function() {
  return enabledItems();
};
Template.processedItems.items = function() {
  return processedItems();
};

If you think about it, it is better this way as if you insert (locally) an item which is both enabled and processed, it can appear in both lists (a opposed to if you had two separate collections).

NOTE

I realised I was kind of unclear, so I've expanded this a little, hope it helps.

Community
  • 1
  • 1
Tom Coleman
  • 3,037
  • 18
  • 16
  • The example above is simplified. Assume the two subsets don't necessarily hold the same properties. It may be hard to visualize, but I promise you this is crucial when dealing with large amounts of data -- you do not want to send any more than what the client needs to see. I will try to think of a better example that illustrates the use cases – matb33 Sep 28 '12 at 16:49
  • 1
    In the app I'm building with meteor I have two types of messages, basically doing the same thing (have the same "schema") but are published differently. For now I use two collections, as I didn't want to "patch" Meteor... – Andreas Sep 28 '12 at 19:39
  • I'm not proposing you send all the items across; just that you use the same query on the client side (that you already are server side) to pull them back out again when you need to use them. – Tom Coleman Sep 29 '12 at 03:04
  • I still plan on writing up a different scenario, but maybe a quick one here might help: Imagine the first subset consists of records 50000 to 50010 (like in pagination), and your second subset is all "processed" records across all the millions of records. Two subsets, one collection. And you need to display both of these at the same time. This type of requirement becomes more and more prevalent as an app increases beyond weekend hacks – matb33 Sep 30 '12 at 17:27
  • So you are saying that the problem is there is no way to select out the elements that you want client side (because you can't tell they are records 50k to 50.1k)? I guess you are stuck there, unless you resort to hacks like publishing a synthetic field on the document (`sortOrder` or something). OTOH, I don't really see how latency compensation could ever work in this case either. – Tom Coleman Oct 01 '12 at 01:05
  • Well, I'd be able to tell which records they are, I would expect I'd have _ids. The issue lies in that the two pubsubs (50000 to 50010 and all 100 processed records) must both be represented with the same collection name on client and server. I think I need to clean this question up and post fresh. A simple and clear example is what is needed... I'll repost shortly – matb33 Oct 02 '12 at 00:10
  • Tom, if you complete your example and throw in a `processedItems()` function too, we're going to run into the same issue outlined in my original question. The local `Items` collection will only be able to hold one of the two subscriptions at a time – matb33 Oct 03 '12 at 22:47
  • 1
    OK well that completely turns my understanding of how Meteor's pubsub works upside down =) I did try it and it's working, and it appears that my `Items` collection contains exactly the right amount of records between the two subscriptions, no more no less. I'm going to dig a bit deeper to understand how that works. Thanks Tom – matb33 Oct 04 '12 at 03:08
  • No worries! Jump on IRC sometime (now?) if you want to chat about it further.. (I'm tmeasday) – Tom Coleman Oct 04 '12 at 03:18
  • I've continued testing and I believe I may have run into the issue I was talking about, but I'm not quite sure. To recreate, add another criteria in the selectors, i.e.: `return Items.find({enabled: true, title: new RegExp(RegExp.escape(search1 || ""), 'i')})`. Then wire up `search1` in whatever manner you prefer so that it reaches the query. Repeat with `search2` and another input field for `processed: true`. Because we use the same `Items` collection, we get some weird mixed results. So maybe we're not quite there yet? – matb33 Oct 04 '12 at 03:53
  • These are just client side searches? They should work fine. Although I think there was something about minimongo not supporting regexps properly: https://github.com/meteor/meteor/issues/346#issuecomment-8740773 – Tom Coleman Oct 04 '12 at 04:14
  • Might be easier to use the same test code I'm playing with: http://pastebin.com/QjRLQ9M4 – matb33 Oct 04 '12 at 04:15
  • No these would be server-side searches. For instance, if the server collection was huge, using the server-side search to narrow down what to send to the client. The funny business is also much more evident when you just do searching on `title` (i.e. remove `enabled: true, ` and `processed: true, ` in the selectors). – matb33 Oct 04 '12 at 04:17
  • Seems to work fine for me. PS this would be a lot easier on IRC if you can get on #meteor... – Tom Coleman Oct 04 '12 at 04:20
  • For those who were following this, there was a small bug in the pastebin code. See the link below for a working version: http://pastebin.com/jsUnDsJC – matb33 Oct 04 '12 at 19:49
  • What if the query is using 'limit', right now it is impossible to do that client side and have reactivation. – Rad The Mad Nov 24 '12 at 22:18
  • Ok, nice solution. One last question. If you have Meteor.userId() in your filter, how would you update `enabledItems` library function? – pihentagy Feb 10 '15 at 23:05
  • I would probably pass an id in as an argument, rather than calling `Meteor.userId` in the function. – Tom Coleman Feb 12 '15 at 01:23
6

you could make two separate publications like this..

Server publications

Meteor.publish("enabled_items", function(){
    var self = this;

    var handle = Items.find({enabled: true}).observe({
        added: function(item){
            self.set("enabled_items", item._id, item);
            self.flush();
        },
        changed: function(item){
            self.set("enabled_items", item._id, item);
            self.flush();
        }
    });

    this.onStop(function() {
        handle.stop();
    });
});

Meteor.publish("disabled_items", function(){
    var self = this;

    var handle = Items.find({enabled: false}).observe({
        added: function(item){
            self.set("disabled_items", item._id, item);
            self.flush();
        },
        changed: function(item){
            self.set("disabled_items", item._id, item);
            self.flush();
        }
    });

    this.onStop(function() {
        handle.stop();
    });
});

Client Subscriptions

var EnabledItems = new Meteor.Collection("enabled_items"),
    DisabledItems = new Meteor.Collection("disabled_items");

Meteor.subscribe("enabled_items");
Meteor.subscribe("disabled_items");
Lloyd
  • 8,204
  • 2
  • 38
  • 53
  • Close, but since there's no publish on the client, the two client collections can't see each other. The latency compensation technique won't work either, which is the crux of all this – matb33 Sep 30 '12 at 20:55
  • for security reasons, i don't permit any writes to any Collection at the client-side (else anyone can just open Dev Tools and modify the collection e.g. `Items.insert({some:data})`).. instead i do my writes server-side using Meteor.Methods and implement method stubs client-side to get Latency Compensation. – Lloyd Sep 30 '12 at 22:32
  • Lloyd, that's exactly what I've had to resort to. We'll have to change that when the auth branch lands into master. But this is slightly different. Your stub still does a local insert so that changes occur immediately, and the server gives the final yay or nay. Try running your app in Firefox, then under the file menu, click Work Offline. Try adding a few records. Do they appear despite no connection to the server? This is a good litmus test to find out if you have latency compensation working correctly. – matb33 Oct 01 '12 at 00:08
  • 1
    Latency Compensation works fine with expected behaviour - i just tested my app in Firefox as suggested. – Lloyd Oct 01 '12 at 09:46
  • i tested both updating and inserting records in the Method stub. Changes are reflected immediately on the screen, then corrected once the server response arrives. – Lloyd Oct 01 '12 at 09:55
  • These were on records that you were publishing in the way you suggested, with the observe? I'm going to go try this, though I'm not sure how it's working – matb33 Oct 02 '12 at 00:00
  • what version of Meteor are you using? – Lloyd Oct 02 '12 at 08:25
  • Lloyd, latest version 0.4.2. In your original answer, the example you give has two collections "enabled_items" and "disabled_items". By implementing a Meteor.methods strategy, are you saying you will get latency compensation this way? Technically that shouldn't work, or at least only partially. When running the "insert" in your client stub, on which collection would you be doing on? EnabledItems or DisabledItems? See where I'm getting at? You'd need to have the original Items collection on the client to insert on it in your stub. And those changes won't reflect to the other client collections – matb33 Oct 03 '12 at 01:50
  • The method stub gives you thr chance to apply the expected changes immediately, before the server confirms. That in itself is latency compensation. In my example there is no "items" collection on the client. So, if you expect your method call to make an item active, then in your stub move the item from DisabledItems to EnabledItems. – Lloyd Oct 03 '12 at 06:20
  • By the way I probably threw you off a bit by not correcting your examples... it was meant to be `EnabledItems` and `ProcessedItems`. That might have affected your answers, apologies – matb33 Oct 03 '12 at 22:55
  • Lloyd, I tried your technique, but for some reason the template for the first collection gets all the data. The template for the second one is empty. If I check my collections through in the console, then they are there. – Rad The Mad Nov 24 '12 at 20:57
  • ok.. the code i posted was purely to get the data into the Collections, which you say is working - i've no idea what you are doing downstream of that.. – Lloyd Nov 24 '12 at 21:17
  • Lloyd, actually the data seems to be the FIRST subscribed collection. The docs state "If more than one subscription sends conflicting values for an attribute (same collection name, document ID, and attribute name), then the value on the client will be that from the first subscription the client activated. (Even if it is not the first to send the duplicated attribute.)". How do I get around that? – Rad The Mad Nov 24 '12 at 21:56
  • In my example i publish two distinct collections.. I abandoned Meteor two months ago, i'm a little rusty.. – Lloyd Nov 25 '12 at 07:06
  • thanks, this technique is great, the api i think has changed a little since i think this was answered. but the concept is great and still holds. – looshi Mar 26 '15 at 05:54
1

I've managed to achieve some promising preliminary results by approaching the problem with a single publish/subscribe per collection, and leveraging $or in the find query.

The idea is to provide a wrapper around Meteor.Collection that allows you to add "views", which are basically named cursors. But what's really happening is that these cursors aren't run individually... their selectors are extracted, $or'd together and run as a single query and onto a single pub-sub.

It's not perfect, in that an offset/limit won't work with this technique, but at the moment minimongo doesn't support it anyway.

But ultimately what it allows you to do is to declare what looks like different subsets of the same collection, but under the hood they are the same subset. There's just a bit of abstraction in front to make them feel cleanly separated.

Example:

// Place this code in a file read by both client and server:
var Users = new Collection("users");
Users.view("enabledUsers", function (collection) {
    return collection.find({ enabled: true }, { sort: { name: 1 } });
});

Or if you want to pass parameters:

Users.view("filteredUsers", function (collection) {
    return collection.find({ enabled: true, name: this.search, { sort: { name: 1 } });
}, function () {
    return { search: Session.get("searchterms"); };
});

The parameters are given as objects, because it's a single publish/subscribe $or'd together, I needed a way to get the right parameters since they get mixed together.

And to actually use it in a template:

Template.main.enabledUsers = function () {
    return Users.get("enabledUsers");
};
Template.main.filteredUsers = function () {
    return Users.get("filteredUsers");
};

In short, I take advantage of having the same code running in both server and client, and if the server isn't doing something, the client will, or vice versa.

And most importantly, only the records you are interested in are getting sent down to the client. This is all achievable without an abstraction layer by simply doing the $or yourself, but that $or will get pretty ugly as more subsets get added. This just helps manage it with minimal code.

I wrote this quickly to test it out, apologies for the length and lack of documentation:

test.js

// Shared (client and server)
var Collection = function () {
    var SimulatedCollection = function () {
        var collections = {};

        return function (name) {
            var captured = {
                find: [],
                findOne: []
            };

            collections[name] = {
                find: function () {
                    captured.find.push(([]).slice.call(arguments));
                    return collections[name];
                },
                findOne: function () {
                    captured.findOne.push(([]).slice.call(arguments));
                    return collections[name];
                },
                captured: function () {
                    return captured;
                }
            };

            return collections[name];
        };
    }();

    return function (collectionName) {
        var collection = new Meteor.Collection(collectionName);
        var views = {};

        Meteor.startup(function () {
            var viewName, view, pubName, viewNames = [];

            for (viewName in views) {
                view = views[viewName];
                viewNames.push(viewName);
            }

            pubName = viewNames.join("__");

            if (Meteor.publish) {
                Meteor.publish(pubName, function (params) {
                    var viewName, view, selectors = [], simulated, captured;

                    for (viewName in views) {
                        view = views[viewName];

                        // Run the query callback but provide a SimulatedCollection
                        // to capture what is attempted on the collection. Also provide
                        // the parameters we would be passing as the context:
                        if (_.isFunction(view.query)) {
                            simulated = view.query.call(params, SimulatedCollection(collectionName));
                        }

                        if (simulated) {
                            captured = simulated.captured();
                            if (captured.find) {
                                selectors.push(captured.find[0][0]);
                            }
                        }
                    }

                    if (selectors.length > 0) {
                        return collection.find({ $or: selectors });
                    }
                });
            }

            if (Meteor.subscribe) {
                Meteor.autosubscribe(function () {
                    var viewName, view, params = {};

                    for (viewName in views) {
                        view = views[viewName];
                        params = _.extend(params, view.params.call(this, viewName));
                    }

                    Meteor.subscribe.call(this, pubName, params);
                });
            }
        });

        collection.view = function (viewName, query, params) {
            // Store in views object -- we will iterate over it on startup
            views[viewName] = {
                collectionName: collectionName,
                query: query,
                params: params
            };

            return views[viewName];
        };

        collection.get = function (viewName, optQuery) {
            var query = views[viewName].query;
            var params = views[viewName].params.call(this, viewName);

            if (_.isFunction(optQuery)) {
                // Optional alternate query provided, use it instead
                return optQuery.call(params, collection);
            } else {
                if (_.isFunction(query)) {
                    // In most cases, run default query
                    return query.call(params, collection);
                }
            }
        };

        return collection;
    };
}();

var Items = new Collection("items");

if (Meteor.isServer) {
    // Bootstrap data -- server only
    Meteor.startup(function () {
        if (Items.find().count() === 0) {
            Items.insert({title: "item #01", enabled: true, processed: true});
            Items.insert({title: "item #02", enabled: false, processed: false});
            Items.insert({title: "item #03", enabled: false, processed: false});
            Items.insert({title: "item #04", enabled: false, processed: false});
            Items.insert({title: "item #05", enabled: false, processed: true});
            Items.insert({title: "item #06", enabled: true, processed: true});
            Items.insert({title: "item #07", enabled: false, processed: true});
            Items.insert({title: "item #08", enabled: true, processed: false});
            Items.insert({title: "item #09", enabled: false, processed: true});
            Items.insert({title: "item #10", enabled: true, processed: true});
            Items.insert({title: "item #11", enabled: true, processed: true});
            Items.insert({title: "item #12", enabled: true, processed: false});
            Items.insert({title: "item #13", enabled: false, processed: true});
            Items.insert({title: "item #14", enabled: true, processed: true});
            Items.insert({title: "item #15", enabled: false, processed: false});
        }
    });
}

Items.view("enabledItems", function (collection) {
    return collection.find({
        enabled: true,
        title: new RegExp(RegExp.escape(this.search1 || ""), "i")
    }, {
        sort: { title: 1 }
    });
}, function () {
    return {
        search1: Session.get("search1")
    };
});

Items.view("processedItems", function (collection) {
    return collection.find({
        processed: true,
        title: new RegExp(RegExp.escape(this.search2 || ""), "i")
    }, {
        sort: { title: 1 }
    });
}, function () {
    return {
        search2: Session.get("search2")
    };
});

if (Meteor.isClient) {
    // Client-only templating code

    Template.main.enabledItems = function () {
        return Items.get("enabledItems");
    };
    Template.main.processedItems = function () {
        return Items.get("processedItems");
    };

    // Basic search filtering
    Session.get("search1", "");
    Session.get("search2", "");

    Template.main.search1 = function () {
        return Session.get("search1");
    };
    Template.main.search2 = function () {
        return Session.get("search2");
    };
    Template.main.events({
        "keyup [name='search1']": function (event, template) {
            Session.set("search1", $(template.find("[name='search1']")).val());
        },
        "keyup [name='search2']": function (event, template) {
            Session.set("search2", $(template.find("[name='search2']")).val());
        }
    });
    Template.main.preserve([
        "[name='search1']",
        "[name='search2']"
    ]);
}

// Utility, shared across client/server, used for search
if (!RegExp.escape) {
    RegExp.escape = function (text) {
        return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
    };
}

test.html

<head>
    <title>Collection View Test</title>
</head>

<body>
    {{> main}}
</body>

<template name="main">
    <h1>Collection View Test</h1>
    <div style="float: left; border-right: 3px double #000; margin-right: 10px; padding-right: 10px;">
        <h2>Enabled Items</h2>
        <input type="text" name="search1" value="{{search1}}" placeholder="search this column" />
        <ul>
            {{#each enabledItems}}
                <li>{{title}}</li>
            {{/each}}
        </ul>
    </div>
    <div style="float: left;">
        <h2>Processed Items</h2>
        <input type="text" name="search2" value="{{search2}}" placeholder="search this column" />
        <ul>
            {{#each processedItems}}
                <li>{{title}}</li>
            {{/each}}
        </ul>
    </div>
</template>
matb33
  • 2,820
  • 1
  • 19
  • 28
  • 1
    Mat - first off, do you realise that if you publish the same collection twice (with different subscription names + filters), you end up with the "$or-ed" data in the collection on the client? – Tom Coleman Oct 03 '12 at 03:35
  • Secondly, your `Collection.view(..)` method is cool (kind of like an ActiveRecord scope), but isn't it fundamentally just doing what I suggested in my answer (using the same query on the client + server)? – Tom Coleman Oct 03 '12 at 03:37
  • I've updated my answer to be a little clearer, hope it helps. – Tom Coleman Oct 03 '12 at 03:45
  • Tom, yes you're right, fundamentally it is. And it isn't capable of solving the 50000 to 50010 example either. But the advantage is that by merging the results of multiple queries into a single cursor, we can have all the records involved in the various pub-subs available to the client, and no more. We then have the client refilter again (by running the same `find`) to re-narrow down the query. Boy that sounds confusing, but it makes sense to me! ;-) – matb33 Oct 03 '12 at 22:49
  • Tom, I'm also unclear as to the first comment you made about publishing the same collection twice. I'm not quite sure what you mean. I'm guessing you've uncovered a major flaw in my experiment =) – matb33 Oct 03 '12 at 22:50
  • Not a flaw, just that you are missing something nice that meteor does. – Tom Coleman Oct 03 '12 at 22:56