Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Overhaul quiescence, method callback timing, and login methods on the…

… client.

- Data streamed from the server is quiesced on a per-object basis, not a global
  basis. We track which documents a method stubs modifies, and create individual
  snapshots ("server documents") of those documents rather than whole-Collection
  snapshots. Data writes from the server to documents not modified by stubs are
  applied immediately to the local cache; other writes are applied to the
  "server document" snapshots. Server documents are flushed to the local cache
  when all method stubs that wrote to the document have sent their "data write"
  message.  (We still do "full database" quiescence after a reconnect.)

- Instead of calling method callbacks as soon as the result is received, we wait
  until all data that precedes their "data done" message is flushed to the local
  cache. This way, method callbacks can see all of their results locally. (This
  applies to Collection mutator callbacks as well.) If this delay is
  unacceptable, you can also specify the onResultReceived option to
  Meteor.apply; this callback is given the method result as soon as it comes in,
  and there's no guarantee that the local cache is up to date.
  (This is a client-only change: server-side callbacks do not block on the
  write fence.)

- Methods invoked with the "wait" option to Meteor.apply now wait until all
  preceding methods are fully finished to be *sent*, not just to call their
  callbacks. ie, previous calls block the "wait" method in the same way that
  "wait" methods block subsequent calls.

- Remove Meteor.userLoaded and {{currentUserLoaded}}.
  Meteor.userId() is now set only at the point where Meteor.user() is fully
      loaded.
  Current user data is published via an unnamed subscription, not via
      "meteor.currentUser".
  Replace them with Meteor.loggingIn() and {{loggingIn}}, which become true
      as soon as the login method is sent (instead of only once it succeeds).
  In accounts-ui, move the spinny into the dropdown, because it now shows up
      before error messages would.

- Previously, if we received the "result" message from a method but no "data"
  message, and then disconnected and reconnected, quiescence would be
  permanently blocked. Now, not only do we allow the app to continue working,
  but we even guarantee that the method's callback will be called at the
  "reconnect quiescence" point.

- Remove reset function from the Store API (the interface between
      _LivedataConnection and Collection), and add a boolean "reset" argument to
      beginUpdate instead.
  Add saveOriginals/retrieveOriginals functions to the Store API (pass-through
      to minimongo implementation).
  Allow "replace" messages to be passed to the Store API's update function
      (in addition to set/unset).
  Allow Store API implementations (eg tinytest_client) to not specify all
      functions.

- Server-side tinytest results now stream into the result page instead of
  appearing all at once at the end.

- Rename fields and methods of Meteor._LivedataConnection as camelCase, and
  prepend all internal fields with _.

- Different Meteor._LivedataConnection objects now have separate
  _userIdListeners _ContextSets.

- Remove snapshot/restore functionality from Minimongo collections. (Individual
  queries still have result snapshots.) The "server documents" in
  Meteor._LivedataConnection serve the equivalent purpose.

- Meteor.loginWithToken's callback is now a "call with error on error, call
  with no args on success" callback like the other login callbacks.

- The test-only Meteor._LivedataConnection.onQuiesce function is removed.
  Every single use of it is now supported by normal method callbacks.
  • Loading branch information...
commit f8c54c404618e8af77b50f67ea068166ebf0babe 1 parent c947cbf
@glasser glasser authored
Showing with 1,797 additions and 1,229 deletions.
  1. +24 −35 docs/client/api.html
  2. +18 −17 docs/client/api.js
  3. +1 −1  docs/client/concepts.html
  4. +3 −3 docs/client/docs.js
  5. +3 −3 examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.html
  6. +5 −5 examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js
  7. +126 −46 packages/accounts-base/accounts_client.js
  8. +5 −1 packages/accounts-base/accounts_common.js
  9. +6 −13 packages/accounts-base/accounts_server.js
  10. +9 −13 packages/accounts-base/localstorage_token.js
  11. +4 −1 packages/accounts-base/package.js
  12. +12 −24 packages/accounts-oauth-helper/oauth_client.js
  13. +13 −4 packages/accounts-oauth-helper/oauth_server.js
  14. +11 −24 packages/accounts-password/email_tests.js
  15. +19 −51 packages/accounts-password/password_client.js
  16. +31 −83 packages/accounts-password/password_tests.js
  17. +9 −1 packages/accounts-ui-unstyled/login_buttons.html
  18. +25 −21 packages/accounts-ui-unstyled/login_buttons_dropdown.html
  19. +1 −5 packages/accounts-ui-unstyled/login_buttons_single.html
  20. +4 −1 packages/accounts-ui/login_buttons.less
  21. +711 −366 packages/livedata/livedata_connection.js
  22. +625 −198 packages/livedata/livedata_connection_tests.js
  23. +5 −1 packages/livedata/livedata_server.js
  24. +16 −15 packages/livedata/livedata_test_service.js
  25. +21 −46 packages/livedata/livedata_tests.js
  26. +0 −46 packages/minimongo/minimongo.js
  27. +0 −118 packages/minimongo/minimongo_tests.js
  28. +29 −39 packages/mongo-livedata/allow_tests.js
  29. +49 −30 packages/mongo-livedata/collection.js
  30. +12 −18 packages/tinytest/tinytest_client.js
View
59 docs/client/api.html
@@ -233,6 +233,12 @@ <h2 id="methods_header"><span>Methods</span></h2>
but without waiting for the round trip delay. If a stub throws an
exception it will be logged to the console.
+You use methods all the time, because the database mutators
+([`insert`](#insert), [`update`](#update), [`remove`](#remove)) are implemented
+as methods. When you call any of these functions on the client, you're invoking
+their stub version that update the local cache, and sending the same write
+request to the server. When the server responds, the client updates the local
+cache with the writes that actually occurred on the server.
{{> api_box method_invocation_userId}}
@@ -275,8 +281,11 @@ <h2 id="methods_header"><span>Methods</span></h2>
{{> api_box meteor_call}}
-This is how to invoke a method. It will run the method on the server.
-If a stub is available, it will also run the stub on the client.
+This is how to invoke a method. It will run the method on the server. If a
+stub is available, it will also run the stub on the client. (See also
+[`Meteor.apply`](#meteor_apply), which is identical to `Meteor.call` except that
+you specify the parameters as an array instead of as separate arguments and you
+can specify a few options controlling how the method is executed.)
If you include a callback function as the last argument (which can't be
an argument to the method, since functions aren't serializable), the
@@ -314,12 +323,15 @@ <h2 id="methods_header"><span>Methods</span></h2>
the synchronous `Meteor.call` form from inside a method body, as
described earlier.
-You use this functionality all the time, because the database mutators
-(`insert`, `update`, `remove`) are essentially methods. When you call
-them on the client (whether from inside a method or at top level), you're
-invoking their stub versions that update the local cache, instead of
-their "real" versions that update the database (using credentials known
-only to the server).
+Meteor tracks the database writes performed by methods, both on the client and
+the server, and does not invoke `asyncCallback` until all of the server's writes
+replace the stub's writes in the local cache. In some cases, there can be a lag
+between the method's return value being available and the writes being visible:
+for example, if another method still outstanding wrote to the same document, the
+local cache may not be up to date until the other method finishes as well. If
+you want to process the method's result as soon as it arrives from the server,
+even if the method's writes are not available yet, you can specify an
+`onResultReceived` callback to [`Meteor.apply`](#meteor_apply).
{{> api_box meteor_apply}}
@@ -1160,11 +1172,6 @@ <h2 id="accounts_api"><span>Accounts</span></h2>
`profile`. See [`Meteor.users`](#meteor_users) for more on
the fields used in user documents.
-If the user is logged in but the user's database record is not fully
-loaded yet, this returns an object with only the `_id` field set. During
-this period [`userLoaded`](#meteor_userloaded) will return
-`false`.
-
{{> api_box userId}}
{{> api_box users}}
@@ -1247,22 +1254,10 @@ <h2 id="accounts_api"><span>Accounts</span></h2>
Meteor.users.deny({update: function () { return true; }});
-{{> api_box userLoaded}}
-
-There are some cases when the client knows the id of the logged in user
-but has not yet received the user data from the server. For example, if
-the user is logged in and reloads the page the user data will be
-unavailable during initial page load.
+{{> api_box loggingIn}}
-During these periods, `userLoaded` will return false
-and [`user`](#meteor_user) will return an object with only
-the `_id` key.
-
-{{#note}}
-We realize this is inconvenient. It is a temporary solution. In the
-future we will either make it unnecessary or fold it into a more
-general mechanism.
-{{/note}}
+For example, [the `accounts-ui` package](#accountsui) uses this to display an
+animation while the login request is being processed.
{{> api_box logout}}
@@ -1331,13 +1326,7 @@ <h2 id="accounts_api"><span>Accounts</span></h2>
{{> api_box currentUser}}
-{{> api_box currentUserLoaded}}
-{{#note}}
-We realize this is inconvenient. It is a temporary solution. In the
-future we will either make it unnecessary or fold it into a more
-general mechanism.
-{{/note}}
-
+{{> api_box loggingInTemplate}}
{{> api_box accounts_config}}
{{> api_box accounts_ui_config}}
View
35 docs/client/api.js
@@ -248,19 +248,19 @@ Template.api.error = {
Template.api.meteor_call = {
id: "meteor_call",
- name: "Meteor.call(func, arg1, arg2, ... [, asyncCallback])",
+ name: "Meteor.call(name, param1, param2, ... [, asyncCallback])",
locus: "Anywhere",
descr: ["Invokes a method passing any number of arguments."],
args: [
- {name: "func",
+ {name: "name",
type: "String",
descr: "Name of method to invoke"},
- {name: "arg1, arg2, ...",
+ {name: "param1, param2, ...",
type: "JSON",
descr: "Optional method arguments"},
{name: "asyncCallback",
type: "Function",
- descr: "Optional callback. If passed, the method runs asynchronously, instead of synchronously, and calls asyncCallback passing either the error or the result."}
+ descr: "Optional callback, which is called asynchronously with the error or result after the method is complete. If not provided, the method runs synchronously if possible (see below)."}
]
};
@@ -278,13 +278,15 @@ Template.api.meteor_apply = {
descr: "Method arguments"},
{name: "asyncCallback",
type: "Function",
- descr: "Optional callback. If passed, the method runs asynchronously, instead of synchronously, and calls asyncCallback passing either the error or the result."}
+ descr: "Optional callback; same semantics as in [`Meteor.call`](#meteor_call)."}
],
options: [
{name: "wait",
type: "Boolean",
- descr: "(Client only) If true, don't send any subsequent method calls until this one is completed. "
- + "Only run the callback for this method once all previous method calls have completed."}
+ descr: "(Client only) If true, don't send this method until all previous method calls have completed, and don't send any subsequent method calls until this one is completed."},
+ {name: "onResultReceived",
+ type: "Function",
+ descr: "(Client only) This callback is invoked with the error or result of the method (just like `asyncCallback`) as soon as the error or result is available. The local cache may not yet reflect the writes performed by the method."}
]
};
@@ -317,7 +319,6 @@ Template.api.connect = {
};
// onAutopublish
-// onQuiesce
Template.api.meteor_collection = {
id: "meteor_collection",
@@ -696,7 +697,7 @@ Template.api.user = {
};
Template.api.currentUser = {
- id: "meteor_currentuser",
+ id: "template_currentuser",
name: "{{currentUser}}",
locus: "Handlebars templates",
descr: ["Calls [Meteor.user()](#meteor_user). Use `{{#if currentUser}}` to check whether the user is logged in."]
@@ -717,18 +718,18 @@ Template.api.users = {
descr: ["A [Meteor.Collection](#collections) containing user documents."]
};
-Template.api.userLoaded = {
- id: "meteor_userloaded",
- name: "Meteor.userLoaded()",
+Template.api.loggingIn = {
+ id: "meteor_loggingin",
+ name: "Meteor.loggingIn()",
locus: "Client",
- descr: ["Determine if the current user document is fully loaded in [`Meteor.users`](#meteor_users). A reactive data source."]
+ descr: ["True if a login method (such as `Meteor.loginWithPassword`, `Meteor.loginWithFacebook`, or `Accounts.createUser`) is currently in progress. A reactive data source."]
};
-Template.api.currentUserLoaded = {
- id: "meteor_currentuserloaded",
- name: "{{currentUserLoaded}}",
+Template.api.loggingInTemplate = {
+ id: "template_loggingin",
+ name: "{{loggingIn}}",
locus: "Handlebars templates",
- descr: ["Calls [Meteor.userLoaded()](#meteor_userloaded)."]
+ descr: ["Calls [Meteor.loggingIn()](#meteor_loggingin)."]
};
View
2  docs/client/concepts.html
@@ -291,7 +291,7 @@ <h2 id="reactivity">Reactivity</h2>
* [`Meteor.status`](#meteor_status)
* [`Meteor.user`](#meteor_user)
* [`Meteor.userId`](#meteor_userid)
-* [`Meteor.userLoaded`](#meteor_userloaded)
+* [`Meteor.loggingIn`](#meteor_loggingin)
Meteor's
[implementation](https://github.com/meteor/meteor/blob/master/packages/deps/deps.js)
View
6 docs/client/docs.js
@@ -167,7 +167,7 @@ var toc = [
"Meteor.user",
"Meteor.userId",
"Meteor.users",
- "Meteor.userLoaded",
+ "Meteor.loggingIn",
"Meteor.logout",
"Meteor.loginWithPassword",
{name: "Meteor.loginWithFacebook", id: "meteor_loginwithexternalservice"},
@@ -177,8 +177,8 @@ var toc = [
{name: "Meteor.loginWithWeibo", id: "meteor_loginwithexternalservice"},
{type: "spacer"},
- {name: "{{currentUser}}", id: "meteor_currentuser"},
- {name: "{{currentUserLoaded}}", id: "meteor_currentuserloaded"},
+ {name: "{{currentUser}}", id: "template_currentuser"},
+ {name: "{{loggingIn}}", id: "template_loggingin"},
{type: "spacer"},
"Accounts.config",
View
6 examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.html
@@ -95,9 +95,9 @@
{{button "modals" "justVerifiedEmail" "Verified Email"}}
</div>
<div class="group">
- <h3>Spinner (must be logged in)</h3>
- {{radio "fakeUserNotLoaded" "false" "Off"}}
- {{radio "fakeUserNotLoaded" "true" "Pretend userLoaded=false"}}
+ <h3>Logging-in Spinner</h3>
+ {{radio "fakeLoggingIn" "false" "Off"}}
+ {{radio "fakeLoggingIn" "true" "Pretend loggingIn=true"}}
</div>
<div class="group">
<h3>Background Color</h3>
View
10 examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js
@@ -4,7 +4,7 @@ Meteor.users.allow({update: function () { return true; }});
if (Meteor.isClient) {
Accounts.STASH = _.extend({}, Accounts);
- Accounts.STASH.userLoaded = Meteor.userLoaded;
+ Accounts.STASH.loggingIn = Meteor.loggingIn;
var handleSetting = function (key, value) {
if (key === "numServices") {
@@ -31,9 +31,9 @@ if (Meteor.isClient) {
}
} else if (key === "signupFields") {
Accounts.ui._options.passwordSignupFields = value;
- } else if (key === "fakeUserNotLoaded") {
- Meteor.userLoaded = (value ? function () { return false; } :
- Accounts.STASH.userLoaded);
+ } else if (key === "fakeLoggingIn") {
+ Meteor.loggingIn = (value ? function () { return true; } :
+ Accounts.STASH.loggingIn);
}
};
@@ -44,7 +44,7 @@ if (Meteor.isClient) {
numServices: 3,
hasPasswords: true,
signupFields: 'EMAIL_ONLY',
- fakeUserNotLoaded: false,
+ fakeLoggingIn: false,
bgcolor: 'white'
});
else
View
172 packages/accounts-base/accounts_client.js
@@ -1,70 +1,150 @@
(function () {
+ // This is reactive.
Meteor.userId = function () {
return Meteor.default_connection.userId();
};
- var userLoadedListeners = new Meteor.deps._ContextSet;
- var currentUserSubscriptionData;
-
- Meteor.userLoaded = function () {
- userLoadedListeners.addCurrentContext();
- return currentUserSubscriptionData && currentUserSubscriptionData.loaded;
+ var loggingIn = false;
+ var loggingInListeners = new Meteor.deps._ContextSet;
+ var setLoggingIn = function (x) {
+ loggingIn = x;
+ loggingInListeners.invalidateAll();
+ };
+ Meteor.loggingIn = function () {
+ loggingInListeners.addCurrentContext();
+ return loggingIn;
};
- // This calls userId and userLoaded, both of which are reactive.
+ // This calls userId, which is reactive.
Meteor.user = function () {
var userId = Meteor.userId();
if (!userId)
return null;
- if (Meteor.userLoaded()) {
- var user = Meteor.users.findOne(userId);
- if (user) return user;
- }
- // Either the subscription isn't done yet, or for some reason this user has
- // no published fields (and thus is considered to not exist in
- // minimongo). Return a minimal object.
+ var user = Meteor.users.findOne(userId);
+ if (user) return user;
+
+ // For some reason this user has no published fields (and thus is considered
+ // to not exist in minimongo). Return a minimal object.
return {_id: userId};
};
+ // Call a login method on the server.
+ //
+ // A login method is a method which on success calls `this.setUserId(id)` on
+ // the server and returns an object with fields 'id' (containing the user id)
+ // and 'token' (containing a resume token).
+ //
+ // This function takes care of:
+ // - Updating the Meteor.loggingIn() reactive data source
+ // - Calling the method in 'wait' mode
+ // - On success, saving the resume token to localStorage
+ // - On success, calling Meteor.default_connection.setUserId()
+ // - Setting up an onReconnect handler which logs in with
+ // the resume token
+ //
+ // Options:
+ // - methodName: The method to call (default 'login')
+ // - methodArguments: The arguments for the method
+ // - validateResult: If provided, will be called with the result of the
+ // method. If it throws, the client will not be logged in (and
+ // its error will be passed to the callback).
+ // - userCallback: Will be called with no arguments once the user is fully
+ // logged in, or with the error on error.
+ Accounts.callLoginMethod = function (options) {
+ options = _.extend({
+ methodName: 'login',
+ methodArguments: []
+ }, options);
+ // Set defaults for callback arguments to no-op functions; make sure we
+ // override falsey values too.
+ _.each(['validateResult', 'userCallback'], function (f) {
+ if (!options[f])
+ options[f] = function () {};
+ });
+
+ var reconnected = false;
+
+ // We want to set up onReconnect as soon as we get a result token back from
+ // the server, without having to wait for subscriptions to rerun. This is
+ // because if we disconnect and reconnect between getting the result and
+ // getting the results of subscription rerun, we WILL NOT re-send this
+ // method (because we never re-send methods whose results we've received)
+ // but we WILL call loggedInAndDataReadyCallback at "reconnect quiesce"
+ // time. This will lead to _makeClientLoggedIn(result.id) even though we
+ // haven't actually sent a login method!
+ //
+ // But by making sure that we send this "resume" login in that case (and
+ // calling _makeClientLoggedOut if it fails), we'll end up with an accurate
+ // client-side userId. (It's important that livedata_connection guarantees
+ // that the "reconnect quiesce"-time call to loggedInAndDataReadyCallback
+ // will occur before the callback from the resume login call.)
+ var onResultReceived = function (err, result) {
+ if (err || !result || !result.token) {
+ Meteor.default_connection.onReconnect = null;
+ } else {
+ Meteor.default_connection.onReconnect = function() {
+ reconnected = true;
+ Accounts.callLoginMethod({
+ methodArguments: [{resume: result.token}],
+ userCallback: function (error) {
+ if (error) {
+ Accounts._makeClientLoggedOut();
+ }
+ options.userCallback(error);
+ }});
+ };
+ }
+ };
+
+ // This callback is called once the local cache of the current-user
+ // subscription (and all subscriptions, in fact) are guaranteed to be up to
+ // date.
+ var loggedInAndDataReadyCallback = function (error, result) {
+ // If the login method returns its result but the connection is lost
+ // before the data is in the local cache, it'll set an onReconnect (see
+ // above). The onReconnect will try to log in using the token, and *it*
+ // will call userCallback via its own version of this
+ // loggedInAndDataReadyCallback. So we don't have to do anything here.
+ if (reconnected)
+ return;
+
+ setLoggingIn(false);
+ if (error || !result) {
+ error = error || new Error(
+ "No result from call to " + options.methodName);
+ options.userCallback(error);
+ return;
+ }
+ try {
+ options.validateResult(result);
+ } catch (e) {
+ options.userCallback(e);
+ return;
+ }
+
+ // Make the client logged in. (The user data should already be loaded!)
+ Accounts._makeClientLoggedIn(result.id, result.token);
+ options.userCallback();
+ };
+
+ setLoggingIn(true);
+ Meteor.apply(
+ options.methodName,
+ options.methodArguments,
+ {wait: true, onResultReceived: onResultReceived},
+ loggedInAndDataReadyCallback);
+ };
+
Accounts._makeClientLoggedOut = function() {
Accounts._unstoreLoginToken();
Meteor.default_connection.setUserId(null);
Meteor.default_connection.onReconnect = null;
- userLoadedListeners.invalidateAll();
- if (currentUserSubscriptionData) {
- currentUserSubscriptionData.handle.stop();
- currentUserSubscriptionData = null;
- }
};
Accounts._makeClientLoggedIn = function(userId, token) {
Accounts._storeLoginToken(userId, token);
Meteor.default_connection.setUserId(userId);
- Meteor.default_connection.onReconnect = function() {
- Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) {
- if (error) {
- Accounts._makeClientLoggedOut();
- throw error;
- } else {
- // nothing to do
- }
- });
- };
- userLoadedListeners.invalidateAll();
- if (currentUserSubscriptionData) {
- currentUserSubscriptionData.handle.stop();
- }
- var data = currentUserSubscriptionData = {loaded: false};
- data.handle = Meteor.subscribe(
- "meteor.currentUser", function () {
- // Important! We use "data" here, not "currentUserSubscriptionData", so
- // that if we log out and in again before this subscription is ready, we
- // don't make currentUserSubscriptionData look ready just because this
- // older iteration of subscribing is ready.
- data.loaded = true;
- userLoadedListeners.invalidateAll();
- });
};
Meteor.logout = function (callback) {
@@ -79,13 +159,13 @@
};
// If we're using Handlebars, register the {{currentUser}} and
- // {{currentUserLoaded}} global helpers.
+ // {{loggingIn}} global helpers.
if (typeof Handlebars !== 'undefined') {
Handlebars.registerHelper('currentUser', function () {
return Meteor.user();
});
- Handlebars.registerHelper('currentUserLoaded', function () {
- return Meteor.userLoaded();
+ Handlebars.registerHelper('loggingIn', function () {
+ return Meteor.loggingIn();
});
}
View
6 packages/accounts-base/accounts_common.js
@@ -62,8 +62,12 @@ Accounts.ConfigError.prototype.name = 'Accounts.ConfigError';
// popup, declines retina scan, etc)
Accounts.LoginCancelledError = function(description) {
this.message = description;
- this.cancelled = true;
};
+
+// This is used to transmit specific subclass errors over the wire. We should
+// come up with a more generic way to do this (eg, with some sort of symbolic
+// error code rather than a number).
+Accounts.LoginCancelledError.numericError = 0x8acdc2f;
Accounts.LoginCancelledError.prototype = new Error();
Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError';
View
19 packages/accounts-base/accounts_server.js
@@ -22,9 +22,9 @@
Accounts._loginHandlers = [];
- // Try all of the registered login handlers until one of them
- // doesn't return `undefined` (NOT null), meaning it handled this
- // call to `login`. Return that return value.
+ // Try all of the registered login handlers until one of them doesn't return
+ // `undefined`, meaning it handled this call to `login`. Return that return
+ // value, which ought to be a {id/token} pair.
var tryAllLoginHandlers = function (options) {
var result = undefined;
@@ -49,8 +49,8 @@
// @param handler {Function} A function that receives an options object
// (as passed as an argument to the `login` method) and returns one of:
// - `undefined`, meaning don't handle;
- // - `null`, meaning the user didn't actually log in;
- // - {id: userId, accessToken: *}, if the user logged in successfully.
+ // - {id: userId, token: *}, if the user logged in successfully.
+ // - throw an error, if the user failed to log in.
Accounts.registerLoginHandler = function(handler) {
Accounts._loginHandlers.push(handler);
};
@@ -243,14 +243,7 @@
///
// Publish the current user's record to the client.
- // XXX This should just be a universal subscription, but we want to know when
- // we've gotten the data after a 'login' method, which currently requires
- // us to unsub, sub, and wait for onComplete. This is wasteful because
- // we're actually guaranteed to have the data by the time that 'login'
- // returns. But we don't expose a callback to Meteor.apply which lets us
- // know when the data has been processed (ie, quiescence, or at least
- // partial quiescence).
- Meteor.publish("meteor.currentUser", function() {
+ Meteor.publish(null, function() {
if (this.userId)
return Meteor.users.find(
{_id: this.userId},
View
22 packages/accounts-base/localstorage_token.js
@@ -40,17 +40,10 @@
// Login with a Meteor access token
//
-// XXX having errorCallback only here is weird since other login
-// methods will have different callbacks. Standardize this.
-Meteor.loginWithToken = function (token, errorCallback) {
- Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) {
- if (error) {
- errorCallback();
- throw error;
- }
-
- Accounts._makeClientLoggedIn(result.id, result.token);
- });
+Meteor.loginWithToken = function (token, callback) {
+ Accounts.callLoginMethod({
+ methodArguments: [{resume: token}],
+ userCallback: callback});
};
if (!Accounts._preventAutoLogin) {
@@ -62,8 +55,11 @@ if (!Accounts._preventAutoLogin) {
// request is in flight. This reduces page flicker on startup.
var userId = Accounts._storedUserId();
userId && Meteor.default_connection.setUserId(userId);
- Meteor.loginWithToken(token, function () {
- Accounts._makeClientLoggedOut();
+ Meteor.loginWithToken(token, function (err) {
+ if (err) {
+ Meteor._debug("Error logging in with token: " + err);
+ Accounts._makeClientLoggedOut();
+ }
});
}
}
View
5 packages/accounts-base/package.js
@@ -14,8 +14,11 @@ Package.on_use(function (api) {
api.add_files('accounts_common.js', ['client', 'server']);
api.add_files('accounts_server.js', 'server');
- api.add_files('localstorage_token.js', 'client');
+ // accounts_client must be before localstorage_token, because
+ // localstorage_token attempts to call functions in accounts_client (eg
+ // Accounts.callLoginMethod) on startup.
api.add_files('accounts_client.js', 'client');
+ api.add_files('localstorage_token.js', 'client');
});
Package.on_test(function (api) {
View
36 packages/accounts-oauth-helper/oauth_client.js
@@ -32,30 +32,18 @@
// access in the popup this should log the user in, otherwise
// nothing should happen.
var tryLoginAfterPopupClosed = function(state, callback) {
- Meteor.apply('login', [
- {oauth: {state: state}}
- ], {wait: true}, function(error, result) {
- if (error) {
- // got an error from the server. report it back.
- callback && callback(error);
- } else if (!result) {
- // got an empty response from the server. This means our oauth
- // state wasn't recognized, which could be either because the
- // popup was closed by the user before completion, or some sort
- // of error where the oauth provider didn't talk to our server
- // correctly and closed the popup somehow.
- //
- // we assume it was user canceled, and report it as such. this
- // will mask failures where things are misconfigured such that
- // the server doesn't see the request but does close the
- // window. This seems unlikely.
- callback &&
- callback(new Accounts.LoginCancelledError("Popup closed"));
- } else {
- Accounts._makeClientLoggedIn(result.id, result.token);
- callback && callback();
- }
- });
+ Accounts.callLoginMethod({
+ methodArguments: [{oauth: {state: state}}],
+ userCallback: callback && function (err) {
+ // Allow server to specify a specify subclass of errors. We should come
+ // up with a more generic way to do this!
+ if (err && err instanceof Meteor.Error &&
+ err.error === Accounts.LoginCancelledError.numericError) {
+ callback(new Accounts.LoginCancelledError(err.details));
+ } else {
+ callback(err);
+ }
+ }});
};
var openCenteredPopup = function(url, width, height) {
View
17 packages/accounts-oauth-helper/oauth_server.js
@@ -49,10 +49,19 @@
return undefined; // don't handle
var result = Accounts.oauth._loginResultForState[options.oauth.state];
- if (result === undefined) // not using `!result` since can be null
- // We weren't notified of the user authorizing the login.
- return null;
- else if (result instanceof Error)
+ if (!result) {
+ // OAuth state is not recognized, which could be either because the popup
+ // was closed by the user before completion, or some sort of error where
+ // the oauth provider didn't talk to our server correctly and closed the
+ // popup somehow.
+ //
+ // we assume it was user canceled, and report it as such, using a
+ // Meteor.Error which the client can recognize. this will mask failures
+ // where things are misconfigured such that the server doesn't see the
+ // request but does close the window. This seems unlikely.
+ throw new Meteor.Error(Accounts.LoginCancelledError.numericError,
+ 'No matching login attempt found');
+ } else if (result instanceof Error)
// We tried to login, but there was a fatal error. Report it back
// to the user.
throw result;
View
35 packages/accounts-password/email_tests.js
@@ -80,18 +80,10 @@
}));
};
- var waitUntilLoggedIn = function (test, expect) {
- var unblockNextFunction = expect();
- var quiesceCallback = function () {
- Meteor.autorun(function (handle) {
- if (!Meteor.userLoaded()) return;
- handle.stop();
- unblockNextFunction();
- });
- };
+ var loggedIn = function (test, expect) {
return expect(function (error) {
test.equal(error, undefined);
- Meteor.default_connection.onQuiesce(quiesceCallback);
+ test.isTrue(Meteor.user());
});
};
@@ -101,7 +93,7 @@
email3 = Meteor.uuid() + "-intercept@example.com";
Accounts.createUser(
{email: email2, password: 'foobar'},
- waitUntilLoggedIn(test, expect));
+ loggedIn(test, expect));
},
function (test, expect) {
test.equal(Meteor.user().emails.length, 1);
@@ -114,8 +106,7 @@
getVerifyEmailToken(email2, test, expect);
},
function (test, expect) {
- // Log out, to test that verifyEmail logs us back in. (And if we don't
- // do that, waitUntilLoggedIn won't be able to prevent race conditions.)
+ // Log out, to test that verifyEmail logs us back in.
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
@@ -123,7 +114,7 @@
},
function (test, expect) {
Accounts.verifyEmail(verifyEmailToken,
- waitUntilLoggedIn(test, expect));
+ loggedIn(test, expect));
},
function (test, expect) {
test.equal(Meteor.user().emails.length, 1);
@@ -135,16 +126,12 @@
"addEmailForTestAndVerify", email3,
expect(function (error, result) {
test.isFalse(error);
+ test.equal(Meteor.user().emails.length, 2);
+ test.equal(Meteor.user().emails[1].address, email3);
+ test.isFalse(Meteor.user().emails[1].verified);
}));
},
function (test, expect) {
- Meteor.default_connection.onQuiesce(expect(function () {
- test.equal(Meteor.user().emails.length, 2);
- test.equal(Meteor.user().emails[1].address, email3);
- test.isFalse(Meteor.user().emails[1].verified);
- }));
- },
- function (test, expect) {
getVerifyEmailToken(email3, test, expect);
},
function (test, expect) {
@@ -157,7 +144,7 @@
},
function (test, expect) {
Accounts.verifyEmail(verifyEmailToken,
- waitUntilLoggedIn(test, expect));
+ loggedIn(test, expect));
},
function (test, expect) {
test.equal(Meteor.user().emails[1].address, email3);
@@ -202,7 +189,7 @@
},
function (test, expect) {
Accounts.resetPassword(enrollAccountToken, 'password',
- waitUntilLoggedIn(test, expect));
+ loggedIn(test, expect));
},
function (test, expect) {
test.equal(Meteor.user().emails.length, 1);
@@ -217,7 +204,7 @@
},
function (test, expect) {
Meteor.loginWithPassword({email: email4}, 'password',
- waitUntilLoggedIn(test ,expect));
+ loggedIn(test ,expect));
},
function (test, expect) {
test.equal(Meteor.user().emails.length, 1);
View
70 packages/accounts-password/password_client.js
@@ -9,16 +9,10 @@
delete options.password;
options.srp = verifier;
- Meteor.apply('createUser', [options], {wait: true},
- function (error, result) {
- if (error || !result) {
- error = error || new Error("No result");
- callback && callback(error);
- return;
- }
-
- Accounts._makeClientLoggedIn(result.id, result.token);
- callback && callback();
+ Accounts.callLoginMethod({
+ methodName: 'createUser',
+ methodArguments: [options],
+ userCallback: callback
});
};
@@ -49,23 +43,13 @@
}
var response = srp.respondToChallenge(result);
- Meteor.apply('login', [
- {srp: response}
- ], {wait: true}, function (error, result) {
- if (error || !result) {
- error = error || new Error("No result from call to login");
- callback && callback(error);
- return;
- }
-
- if (!srp.verifyConfirmation({HAMK: result.HAMK})) {
- callback && callback(new Error("Server is cheating!"));
- return;
- }
-
- Accounts._makeClientLoggedIn(result.id, result.token);
- callback && callback();
- });
+ Accounts.callLoginMethod({
+ methodArguments: [{srp: response}],
+ validateResult: function (result) {
+ if (!srp.verifyConfirmation({HAMK: result.HAMK}))
+ throw new Error("Server is cheating!");
+ },
+ userCallback: callback});
});
};
@@ -145,18 +129,10 @@
throw new Error("Need to pass newPassword");
var verifier = Meteor._srp.generateVerifier(newPassword);
- Meteor.apply(
- "resetPassword", [token, verifier], {wait: true},
- function (error, result) {
- if (error || !result) {
- error = error || new Error("No result from call to resetPassword");
- callback && callback(error);
- return;
- }
-
- Accounts._makeClientLoggedIn(result.id, result.token);
- callback && callback();
- });
+ Accounts.callLoginMethod({
+ methodName: 'resetPassword',
+ methodArguments: [token, verifier],
+ userCallback: callback});
};
// Verifies a user's email address based on a token originally
@@ -168,18 +144,10 @@
if (!token)
throw new Error("Need to pass token");
- Meteor.call(
- "verifyEmail", token,
- function (error, result) {
- if (error || !result) {
- error = error || new Error("No result from call to verifyEmail");
- callback && callback(error);
- return;
- }
-
- Accounts._makeClientLoggedIn(result.id, result.token);
- callback && callback();
- });
+ Accounts.callLoginMethod({
+ methodName: 'verifyEmail',
+ methodArguments: [token],
+ userCallback: callback});
};
})();
View
114 packages/accounts-password/password_tests.js
@@ -11,24 +11,10 @@ if (Meteor.isClient) (function () {
test.equal(Meteor.user(), null);
}));
};
-
- var verifyUsername = function (someUsername, test, expect) {
- var callWhenLoaded = expect(function() {
- test.equal(Meteor.user().username, someUsername);
- });
- return function () {
- Meteor.autorun(function(handle) {
- if (!Meteor.userLoaded()) return;
- handle.stop();
- callWhenLoaded();
- });
- };
- };
var loggedInAs = function (someUsername, test, expect) {
- var quiesceCallback = verifyUsername(someUsername, test, expect);
return expect(function (error) {
test.equal(error, undefined);
- Meteor.default_connection.onQuiesce(quiesceCallback);
+ test.equal(Meteor.user().username, someUsername);
});
};
@@ -51,7 +37,6 @@ if (Meteor.isClient) (function () {
password2 = 'password2';
password3 = 'password3';
},
-
function (test, expect) {
Accounts.createUser(
{username: username, email: email, password: password},
@@ -64,50 +49,26 @@ if (Meteor.isClient) (function () {
},
logoutStep,
// This next step tests reactive contexts which are reactive on
- // Meteor.user() without explicitly calling Meteor.userLoaded() --- we want
- // to make sure that user loading finishing invalidates them too.
+ // Meteor.user().
function (test, expect) {
// Set up a reactive context that only refreshes when Meteor.user() is
// invalidated.
- var user;
- var handle1 = Meteor.autorun(function () {
- user = Meteor.user();
+ var loaded = false;
+ var handle = Meteor.autorun(function () {
+ if (Meteor.user() && Meteor.user().emails)
+ loaded = true;
});
// At the beginning, we're not logged in.
- test.equal(user, null);
-
- // This will get called once a second context (which does explicitly call
- // Meteor.userLoaded()) tells us we are ready.
- var callWhenLoaded = expect(function () {
- Meteor.flush();
- // ... and this means that the first context did refresh and give us
- // data.
- test.isTrue(user.emails);
- handle1.stop();
- });
- var waitForLoaded = expect(function () {
- Meteor.autorun(function(handle2) {
- if (!Meteor.userLoaded()) return;
- handle2.stop();
- callWhenLoaded();
- });
- });
+ test.isFalse(loaded);
Meteor.loginWithPassword(username, password, expect(function (error) {
test.equal(error, undefined);
test.notEqual(Meteor.userId(), null);
- // Since userId has changed, the first autorun has been invalidated, so
- // flush will re-run it and user will become not null. In the *CURRENT
- // IMPLEMENTATION*, we will have just called _makeClientLoggedIn which
- // just started a new meteor.currentUser subscription. There is no way
- // that it is complete yet because we haven't gotten back to the event
- // loop to actually get the data, so user.emails hasn't been populated
- // yet. (That said, if we redo how userLoaded is implemented to not
- // involve unsub/sub, it's possible that this test may become flaky by
- // the test.isFalse failing.)
+ // By the time of the login callback, the user should be loaded.
+ test.isTrue(Meteor.user().emails);
+ // Flushing should get us the autorun as well.
Meteor.flush();
- test.notEqual(user, null);
- test.isFalse(user.emails);
- waitForLoaded();
+ test.isTrue(loaded);
+ handle.stop();
}));
},
logoutStep,
@@ -126,37 +87,29 @@ if (Meteor.isClient) (function () {
loggedInAs(username, test, expect));
},
logoutStep,
- // plain text password. no API for this, have to send a raw message.
+ // plain text password. no API for this, have to invoke callLoginMethod
+ // directly.
function (test, expect) {
- Meteor.call(
+ Accounts.callLoginMethod({
// wrong password
- 'login', {user: {email: email}, password: password2},
- expect(function (error, result) {
+ methodArguments: [{user: {email: email}, password: password2}],
+ userCallback: expect(function (error) {
test.isTrue(error);
- test.isFalse(result);
test.isFalse(Meteor.user());
- }));
+ })});
},
function (test, expect) {
- var quiesceCallback = verifyUsername(username, test, expect);
- Meteor.call(
+ Accounts.callLoginMethod({
// right password
- 'login', {user: {email: email}, password: password},
- expect(function (error, result) {
- test.equal(error, undefined);
- test.isTrue(result.id);
- test.isTrue(result.token);
- // emulate the real login behavior, so as not to confuse test.
- Accounts._makeClientLoggedIn(result.id, result.token);
- Meteor.default_connection.onQuiesce(quiesceCallback);
- }));
+ methodArguments: [{user: {email: email}, password: password}],
+ userCallback: loggedInAs(username, test, expect)
+ });
},
// change password with bad old password. we stay logged in.
function (test, expect) {
- var quiesceCallback = verifyUsername(username, test, expect);
Accounts.changePassword(password2, password2, expect(function (error) {
test.isTrue(error);
- Meteor.default_connection.onQuiesce(quiesceCallback);
+ test.equal(Meteor.user().username, username);
}));
},
// change password with good old password.
@@ -178,18 +131,14 @@ if (Meteor.isClient) (function () {
loggedInAs(username, test, expect));
},
logoutStep,
- // create user with raw password
+ // create user with raw password (no API, need to invoke callLoginMethod
+ // directly)
function (test, expect) {
- var quiesceCallback = verifyUsername(username2, test, expect);
- Meteor.call('createUser', {username: username2, password: password2},
- expect(function (error, result) {
- test.equal(error, undefined);
- test.isTrue(result.id);
- test.isTrue(result.token);
- // emulate the real login behavior, so as not to confuse test.
- Accounts._makeClientLoggedIn(result.id, result.token);
- Meteor.default_connection.onQuiesce(quiesceCallback);
- }));
+ Accounts.callLoginMethod({
+ methodName: 'createUser',
+ methodArguments: [{username: username2, password: password2}],
+ userCallback: loggedInAs(username2, test, expect)
+ });
},
logoutStep,
function(test, expect) {
@@ -243,8 +192,7 @@ if (Meteor.isClient) (function () {
}));
},
function(test, expect) {
- Meteor.call('clearUsernameAndProfile');
- Meteor.default_connection.onQuiesce(expect(function() {
+ Meteor.call('clearUsernameAndProfile', expect(function() {
test.isTrue(Meteor.userId());
var user = Meteor.user();
test.equal(user, {_id: Meteor.userId()});
View
10 packages/accounts-ui-unstyled/login_buttons.html
@@ -32,7 +32,11 @@
{{else}}
{{#with singleService}} {{! at this point there must be only one configured services }}
<div class="login-buttons-with-only-one-button">
- {{> _loginButtonsLoggedOutSingleLoginButton}}
+ {{#if loggingIn}}
+ {{> _loginButtonsLoggingIn}}
+ {{else}}
+ {{> _loginButtonsLoggedOutSingleLoginButton}}
+ {{/if}}
</div>
{{/with}}
{{/if}}
@@ -51,3 +55,7 @@
<div class="message info-message">{{infoMessage}}</div>
{{/if}}
</template>
+
+<template name="_loginButtonsLoggingIn">
+ <div class="loading">&nbsp;</div>
+</template>
View
46 packages/accounts-ui-unstyled/login_buttons_dropdown.html
@@ -3,29 +3,25 @@
<!-- -->
<template name="_loginButtonsLoggedInDropdown">
<div class="login-link-and-dropdown-list">
- {{#if currentUserLoaded}}
- <a class="login-link-text" id="login-name-link">
- {{displayName}} ▾
- </a>
-
- {{#if dropdownVisible}}
- <div id="login-dropdown-list" class="accounts-dialog">
- <a class="login-close-text">Close</a>
- <div class="login-close-text-clear"></div>
-
- {{#if inMessageOnlyFlow}}
- {{> _loginButtonsMessages}}
+ <a class="login-link-text" id="login-name-link">
+ {{displayName}} ▾
+ </a>
+
+ {{#if dropdownVisible}}
+ <div id="login-dropdown-list" class="accounts-dialog">
+ <a class="login-close-text">Close</a>
+ <div class="login-close-text-clear"></div>
+
+ {{#if inMessageOnlyFlow}}
+ {{> _loginButtonsMessages}}
+ {{else}}
+ {{#if inChangePasswordFlow}}
+ {{> _loginButtonsChangePassword}}
{{else}}
- {{#if inChangePasswordFlow}}
- {{> _loginButtonsChangePassword}}
- {{else}}
- {{> _loginButtonsLoggedInDropdownActions}}
- {{/if}}
+ {{> _loginButtonsLoggedInDropdownActions}}
{{/if}}
- </div>
- {{/if}}
- {{else}}
- <div class="loading">&nbsp;</div>
+ {{/if}}
+ </div>
{{/if}}
</div>
</template>
@@ -51,9 +47,17 @@
{{#if dropdownVisible}}
<div id="login-dropdown-list" class="accounts-dialog">
<a class="login-close-text">Close</a>
+ {{#if loggingIn}}
+ {{> _loginButtonsLoggingIn}}
+ {{/if}}
<div class="login-close-text-clear"></div>
{{> _loginButtonsLoggedOutAllServices}}
</div>
+ {{else}}
+ {{#if loggingIn}}
+ {{! Not normally visible, but if the user closes the dropdown during login.}}
+ {{> _loginButtonsLoggingIn}}
+ {{/if}}
{{/if}}
</div>
</template>
View
6 packages/accounts-ui-unstyled/login_buttons_single.html
@@ -15,11 +15,7 @@
<template name="_loginButtonsLoggedInSingleLogoutButton">
<div class="login-text-and-button">
<div class="login-display-name">
- {{#if currentUserLoaded}}
- {{displayName}}
- {{else}}
- <div class="loading">&nbsp;</div>
- {{/if}}
+ {{displayName}}
</div>
<div class="login-button single-login-button" id="login-buttons-logout">Sign Out</div>
</div>
View
5 packages/accounts-ui/login_buttons.less
@@ -118,7 +118,7 @@
.loading {
line-height: 1;
background-image: url(data:image/gif;base64,R0lGODlhEAALAPQAAP///wAAANra2tDQ0Orq6gYGBgAAAC4uLoKCgmBgYLq6uiIiIkpKSoqKimRkZL6+viYmJgQEBE5OTubm5tjY2PT09Dg4ONzc3PLy8ra2tqCgoMrKyu7u7gAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCwAAACwAAAAAEAALAAAFLSAgjmRpnqSgCuLKAq5AEIM4zDVw03ve27ifDgfkEYe04kDIDC5zrtYKRa2WQgAh+QQJCwAAACwAAAAAEAALAAAFJGBhGAVgnqhpHIeRvsDawqns0qeN5+y967tYLyicBYE7EYkYAgAh+QQJCwAAACwAAAAAEAALAAAFNiAgjothLOOIJAkiGgxjpGKiKMkbz7SN6zIawJcDwIK9W/HISxGBzdHTuBNOmcJVCyoUlk7CEAAh+QQJCwAAACwAAAAAEAALAAAFNSAgjqQIRRFUAo3jNGIkSdHqPI8Tz3V55zuaDacDyIQ+YrBH+hWPzJFzOQQaeavWi7oqnVIhACH5BAkLAAAALAAAAAAQAAsAAAUyICCOZGme1rJY5kRRk7hI0mJSVUXJtF3iOl7tltsBZsNfUegjAY3I5sgFY55KqdX1GgIAIfkECQsAAAAsAAAAABAACwAABTcgII5kaZ4kcV2EqLJipmnZhWGXaOOitm2aXQ4g7P2Ct2ER4AMul00kj5g0Al8tADY2y6C+4FIIACH5BAkLAAAALAAAAAAQAAsAAAUvICCOZGme5ERRk6iy7qpyHCVStA3gNa/7txxwlwv2isSacYUc+l4tADQGQ1mvpBAAIfkECQsAAAAsAAAAABAACwAABS8gII5kaZ7kRFGTqLLuqnIcJVK0DeA1r/u3HHCXC/aKxJpxhRz6Xi0ANAZDWa+kEAA7AAAAAAAAAAAA);
- width: 60px;
+ width: 16px;
background-position: center center;
background-repeat: no-repeat;
}
@@ -215,6 +215,9 @@
position: relative;
padding-bottom: 8px;
}
+ #login-dropdown-list .loading {
+ float: right;
+ }
.login-close-text-clear { clear: both; }
View
1,077 packages/livedata/livedata_connection.js
@@ -1,14 +1,13 @@
+(function () {
if (Meteor.isServer) {
// XXX namespacing
var Future = __meteor_bootstrap__.require(path.join('fibers', 'future'));
}
-// list of subscription tokens outstanding during a
-// captureDependencies run. only set when we're doing a run. The fact
-// that this is a singleton means we can't do recursive
-// Meteor.subscriptions(). But what would that even mean?
-// XXX namespacing
-Meteor._capture_subs = null;
+// list of subscription tokens outstanding during a captureDependencies
+// run. only set when we're doing a run. The fact that this is a singleton means
+// we can't do recursive Meteor.autosubscribe().
+var captureSubs = null;
// @param url {String|Object} URL to Meteor app or sockjs endpoint (deprecated),
// or an object as a test hook (see code)
@@ -23,64 +22,130 @@ Meteor._LivedataConnection = function (url, options) {
reloadWithOutstanding: false
}, options);
+ // If set, called when we reconnect, queuing method calls _before_ the
+ // existing outstanding ones. This is the only data member that is part of the
+ // public API!
+ self.onReconnect = null;
+
// as a test hook, allow passing a stream instead of a url.
if (typeof url === "object") {
- self.stream = url;
+ self._stream = url;
// if we have two test streams, auto reload stuff will break because
// the url is used as a key for the migration data.
- self.url = "/debug";
+ url = "/debug";
} else {
- self.url = url;
+ self._stream = new Meteor._Stream(url);
}
- self.last_session_id = null;
- self.stores = {}; // name -> object with methods
- self.method_handlers = {}; // name -> func
- self.next_method_id = 1;
-
- // --- Three classes of outstanding methods ---
-
- // 1. either already sent, or waiting to be sent with no special
- // consideration once we reconnect
- self.outstanding_methods = []; // each item has keys: msg, callback
-
- // 2. the sole outstanding method that needs to be waited on, or null
- // same keys as outstanding_methods (notably wait is implicitly true
- // but not set)
- self.outstanding_wait_method = null; // same keys as outstanding_methods
- // stores response from `outstanding_wait_method` while we wait for
- // previous method calls to complete, as received in _livedata_result
- self.outstanding_wait_method_response = null;
-
- // 3. methods blocked on outstanding_wait_method being completed.
- self.blocked_methods = []; // each item has keys: msg, callback, wait
-
- // if set, called when we reconnect, queuing method calls _before_
- // the existing outstanding ones
- self.onReconnect = null;
- // waiting for data from method
- self.unsatisfied_methods = {}; // map from method_id -> true
- // sub was ready, is no longer (due to reconnect)
- self.unready_subscriptions = {}; // map from sub._id -> true
- // messages from the server that have not been applied
- self.pending_data = []; // array of pending data messages
- // name -> updates for (yet to be created) collection
- self.queued = {};
+ self._lastSessionId = null;
+ self._stores = {}; // name -> object with methods
+ self._methodHandlers = {}; // name -> func
+ self._nextMethodId = 1;
+
+ // Tracks methods which the user has tried to call but which have not yet
+ // called their user callback (ie, they are waiting on their result or for all
+ // of their writes to be written to the local cache). Map from method ID to
+ // MethodInvoker object.
+ self._methodInvokers = {};
+
+ // Tracks methods which the user has called but whose result messages have not
+ // arrived yet.
+ //
+ // _outstandingMethodBlocks is an array of blocks of methods. Each block
+ // represents a set of methods that can run at the same time. The first block
+ // represents the methods which are currently in flight; subsequent blocks
+ // must wait for previous blocks to be fully finished before they can be sent
+ // to the server.
+ //
+ // Each block is an object with the following fields:
+ // - methods: a list of MethodInvoker objects
+ // - wait: a boolean; if true, this block had a single method invoked with
+ // the "wait" option
+ //
+ // There will never be adjacent blocks with wait=false, because the only thing
+ // that makes methods need to be serialized is a wait method.
+ //
+ // Methods are removed from the first block when their "result" is
+ // received. The entire first block is only removed when all of the in-flight
+ // methods have received their results (so the "methods" list is empty) *AND*
+ // all of the data written by those methods are visible in the local cache. So
+ // it is possible for the first block's methods list to be empty, if we are
+ // still waiting for some objects to quiesce.
+ //
+ // Example:
+ // _outstandingMethodBlocks = [
+ // {wait: false, methods: []},
+ // {wait: true, methods: [<MethodInvoker for 'login'>]},
+ // {wait: false, methods: [<MethodInvoker for 'foo'>,
+ // <MethodInvoker for 'bar'>]}]
+ // This means that there were some methods which were sent to the server and
+ // which have returned their results, but some of the data written by
+ // the methods may not be visible in the local cache. Once all that data is
+ // visible, we will send a 'login' method. Once the login method has returned
+ // and all the data is visible (including re-running subs if userId changes),
+ // we will send the 'foo' and 'bar' methods in parallel.
+ self._outstandingMethodBlocks = [];
+
+ // method ID -> array of objects with keys 'collection' and 'id', listing
+ // documents written by a given method's stub. keys are associated with
+ // methods whose stub wrote at least one document, and whose data-done message
+ // has not yet been received.
+ self._documentsWrittenByStub = {};
+ // collection -> id -> "server document" object. A "server document" has:
+ // - "document": the version of the document according the
+ // server (ie, the snapshot before a stub wrote it, amended by any changes
+ // received from the server)
+ // - "writtenByStubs": a set of method IDs whose stubs wrote to the document
+ // whose "data done" messages have not yet been processed
+ self._serverDocuments = {};
+
+ // Array of callbacks to be called after the next update of the local
+ // cache. Used for:
+ // - Calling methodInvoker.dataVisible and sub ready callbacks after
+ // the relevant data is flushed.
+ // - Invoking the callbacks of "half-finished" methods after reconnect
+ // quiescence. Specifically, methods whose result was received over the old
+ // connection (so we don't re-send it) but whose data had not been made
+ // visible.
+ self._afterUpdateCallbacks = [];
+
+ // When we reconnect, we don't apply any incoming data messages to stores
+ // until all subs that had been ready before reconnect are ready again, and
+ // all methods that are active have returned their "data done message"; then
+ // all data messages are processed in one update. The following fields are
+ // used for this "reconnect quiescence" process.
+ //
+ // This buffers the messages that aren't being processed because not all subs
+ // and methods are ready.
+ self._bufferedMessagesAtReconnect = [];
+ // map from method ID -> true for methods active at reconnect time that
+ // haven't sent their "data done" message yet
+ self._reconnectMethods = {}; // map from method_id -> true
+ // map from sub ID -> true for subs that were ready (ie, called the sub
+ // ready callback) before reconnect but haven't become ready again yet
+ self._subsBeingRevived = {}; // map from sub._id -> true
+ // if true, the next data update should reset all stores. (set during
+ // reconnect.)
+ self._resetStores = false;
+
+ // name -> array of updates for (yet to be created) collections
+ self._updatesForUnknownStores = {};
// if we're blocking a migration, the retry func
self._retryMigrate = null;
// metadata for subscriptions
- self.subs = new LocalCollection;
- // keyed by subs._id. value is unset or an array. if set, sub is not
+ self._subCollection = new LocalCollection;
+ // keyed by sub._id. value is unset or an array. if set, sub is not
// yet ready.
- self.sub_ready_callbacks = {};
+ self._subReadyCallbacks = {};
// Per-connection scratch area. This is only used internally, but we
// should have real and documented API for this sort of thing someday.
- self.sessionData = {};
+ self._sessionData = {};
- // just for testing
- self.quiesce_callbacks = [];
+ // Reactive userId.
+ self._userId = null;
+ self._userIdListeners = Meteor.deps && new Meteor.deps._ContextSet;
// Block auto-reload while we're waiting for method responses.
if (!options.reloadWithOutstanding) {
@@ -96,10 +161,7 @@ Meteor._LivedataConnection = function (url, options) {
});
}
- // Setup stream (if not overriden above)
- self.stream = self.stream || new Meteor._Stream(self.url);
-
- self.stream.on('message', function (raw_msg) {
+ self._stream.on('message', function (raw_msg) {
try {
var msg = JSON.parse(raw_msg);
} catch (err) {
@@ -125,14 +187,14 @@ Meteor._LivedataConnection = function (url, options) {
Meteor._debug("discarding unknown livedata message type", msg);
});
- self.stream.on('reset', function () {
+ self._stream.on('reset', function () {
// Send a connect message at the beginning of the stream.
// NOTE: reset is called even on the first connection, so this is
// the only place we send this message.
var msg = {msg: 'connect'};
- if (self.last_session_id)
- msg.session = self.last_session_id;
- self.stream.send(JSON.stringify(msg));
+ if (self._lastSessionId)
+ msg.session = self._lastSessionId;
+ self._stream.send(JSON.stringify(msg));
// Now, to minimize setup latency, go ahead and blast out all of
// our pending methods ands subscriptions before we've even taken
@@ -142,10 +204,6 @@ Meteor._LivedataConnection = function (url, options) {
// (in either direction) since we were disconnected (TCP being
// sloppy about that.)
- // XXX we may have an issue where we lose 'data' messages sent
- // immediately before disconnection.. do we need to add app-level
- // acking of data messages?
-
// If an `onReconnect` handler is set, call it first. Go through
// some hoops to ensure that methods that are called from within
// `onReconnect` get executed _before_ ones that were originally
@@ -158,14 +216,14 @@ Meteor._LivedataConnection = function (url, options) {
// add new subscriptions at the end. this way they take effect after
// the handlers and we don't see flicker.
- self.subs.find().forEach(function (sub) {
- self.stream.send(JSON.stringify(
+ self._subCollection.find().forEach(function (sub) {
+ self._stream.send(JSON.stringify(
{msg: 'sub', id: sub._id, name: sub.name, params: sub.args}));
});
});
if (options.reloadOnUpdate) {
- self.stream.on('update_available', function () {
+ self._stream.on('update_available', function () {
// Start trying to migrate to a new version. Until all packages
// signal that they're ready for a migration, the app will
// continue running normally.
@@ -173,44 +231,146 @@ Meteor._LivedataConnection = function (url, options) {
});
}
- // we never terminate the observe(), since there is no way to
- // destroy a LivedataConnection.. but this shouldn't matter, since we're
- // the only one that holds a reference to the self.subs collection
- self.subs_token = self.subs.find({})._observeUnordered({
+ // we never terminate the observe, since there is no way to destroy a
+ // LivedataConnection... but this shouldn't matter, since we're the only one
+ // that holds a reference to self._subCollection
+ self._subCollection.find({})._observeUnordered({
added: function (sub) {
- self.stream.send(JSON.stringify({
+ self._stream.send(JSON.stringify({
msg: 'sub', id: sub._id, name: sub.name, params: sub.args}));
},
changed: function (sub) {
if (sub.count <= 0) {
// minimongo not re-entrant.
- _.defer(function () { self.subs.remove({_id: sub._id}); });
+ _.defer(function () { self._subCollection.remove({_id: sub._id}); });
}
},
removed: function (obj) {
- self.stream.send(JSON.stringify({msg: 'unsub', id: obj._id}));
+ self._stream.send(JSON.stringify({msg: 'unsub', id: obj._id}));
}
});
};
+// A MethodInvoker manages sending a method to the server and calling the user's
+// callbacks. On construction, it registers itself in the connection's
+// _methodInvokers map; it removes itself once the method is fully finished and
+// the callback is invoked. This occurs when it has both received a result,
+// and the data written by it is fully visible.
+var MethodInvoker = function (options) {
+ var self = this;
+
+ // Public (within this file) fields.
+ self.methodId = options.methodId;
+ self.sentMessage = false;
+
+ self._callback = options.callback;
+ self._connection = options.connection;
+ self._message = JSON.stringify(options.message);
+ self._onResultReceived = options.onResultReceived || function () {};
+ self._methodResult = null;
+ self._dataVisible = false;
+
+ // Register with the connection.
+ self._connection._methodInvokers[self.methodId] = self;
+};
+_.extend(MethodInvoker.prototype, {
+ // Sends the method message to the server. May be called additional times if
+ // we lose the connection and reconnect before receiving a result.
+ sendMessage: function () {
+ var self = this;
+ // This function is called before sending a method (including resending on
+ // reconnect). We should only (re)send methods where we don't already have a
+ // result!
+ if (self.gotResult())
+ throw new Error("sendingMethod is called on method with result");
+
+ // If we're re-sending it, it doesn't matter if data was written the first
+ // time.
+ self._dataVisible = false;
+
+ self.sentMessage = true;
+
+ // Actually send the message.
+ self._connection._stream.send(self._message);
+ },
+ // Invoke the callback, if we have both a result and know that all data has
+ // been written to the local cache.
+ _maybeInvokeCallback: function () {
+ var self = this;
+ if (self._methodResult && self._dataVisible) {
+ // Call the callback. (This won't throw: the callback was wrapped with
+ // bindEnvironment.)
+ self._callback(self._methodResult[0], self._methodResult[1]);
+
+ // Forget about this method.
+ delete self._connection._methodInvokers[self.methodId];
+
+ // Let the connection know that this method is finished, so it can try to
+ // move on to the next block of methods.
+ self._connection._outstandingMethodFinished();
+ }
+ },
+ // Call with the result of the method from the server. Only may be called
+ // once; once it is called, you should not call sendMessage again.
+ // If the user provided an onResultReceived callback, call it immediately.
+ // Then invoke the main callback if data is also visible.
+ receiveResult: function (err, result) {
+ var self = this;
+ if (self.gotResult())
+ throw new Error("Methods should only receive results once");
+ self._methodResult = [err, result];
+ self._onResultReceived(err, result);
+ self._maybeInvokeCallback();
+ },
+ // Call this when all data written by the method is visible. This means that
+ // the method has returns its "data is done" message *AND* all server
+ // documents that are buffered at that time have been written to the local
+ // cache. Invokes the main callback if the result has been received.
+ dataVisible: function () {
+ var self = this;
+ self._dataVisible = true;
+ self._maybeInvokeCallback();
+ },
+ // True if receiveResult has been called.
+ gotResult: function () {
+ var self = this;
+ return !!self._methodResult;
+ }
+});
+
_.extend(Meteor._LivedataConnection.prototype, {
// 'name' is the name of the data on the wire that should go in the
- // store. 'store' should be an object with methods beginUpdate,
- // update, endUpdate, reset. see Collection for an example.
- registerStore: function (name, store) {
+ // store. 'wrappedStore' should be an object with methods beginUpdate, update,
+ // endUpdate, saveOriginals, retrieveOriginals. see Collection for an example.
+ registerStore: function (name, wrappedStore) {
var self = this;
- if (name in self.stores)
+ if (name in self._stores)
return false;
- self.stores[name] = store;
- var queued = self.queued[name] || [];
- store.beginUpdate(queued.length);
- _.each(queued, function (msg) {
- store.update(msg);
- });
- store.endUpdate();
- delete self.queued[name];
+ // Wrap the input object in an object which makes any store method not
+ // implemented by 'store' into a no-op.
+ var store = {};
+ _.each(['update', 'beginUpdate', 'endUpdate', 'saveOriginals',
+ 'retrieveOriginals'], function (method) {
+ store[method] = function () {
+ return (wrappedStore[method]
+ ? wrappedStore[method].apply(wrappedStore, arguments)
+ : undefined);
+ };
+ });
+
+ self._stores[name] = store;
+
+ var queued = self._updatesForUnknownStores[name];
+ if (queued) {
+ store.beginUpdate(queued.length, false);
+ _.each(queued, function (msg) {
+ store.update(msg);
+ });
+ store.endUpdate();
+ delete self._updatesForUnknownStores[name];
+ }
return true;
},
@@ -225,18 +385,18 @@ _.extend(Meteor._LivedataConnection.prototype, {
// Look for existing subs (ignore those with count=0, since they're going to
// get removed on the next time through the event loop).
- var existing = self.subs.find(
+ var existing = self._subCollection.find(
{name: name, args: args, count: {$gt: 0}},
{reactive: false}).fetch();
if (existing && existing[0]) {
// already subbed, inc count.
id = existing[0]._id;
- self.subs.update({_id: id}, {$inc: {count: 1}});
+ self._subCollection.update({_id: id}, {$inc: {count: 1}});
if (callback) {
- if (self.sub_ready_callbacks[id])
- self.sub_ready_callbacks[id].push(callback);
+ if (self._subReadyCallbacks[id])
+ self._subReadyCallbacks[id].push(callback);
else
callback(); // XXX maybe _.defer?
}
@@ -244,23 +404,23 @@ _.extend(Meteor._LivedataConnection.prototype, {
// new sub, add object.
// generate our own id so we can know it w/ a find afterwards.
id = LocalCollection.uuid();
- self.subs.insert({_id: id, name: name, args: args, count: 1});
+ self._subCollection.insert({_id: id, name: name, args: args, count: 1});
- self.sub_ready_callbacks[id] = [];
+ self._subReadyCallbacks[id] = [];
if (callback)
- self.sub_ready_callbacks[id].push(callback);
+ self._subReadyCallbacks[id].push(callback);
}
// return an object with a stop method.
var token = {stop: function () {
if (!id) return; // must have an id (local from above).
// just update the database. observe takes care of the rest.
- self.subs.update({_id: id}, {$inc: {count: -1}});
+ self._subCollection.update({_id: id}, {$inc: {count: -1}});
}};
- if (Meteor._capture_subs)
- Meteor._capture_subs.push(token);
+ if (captureSubs)
+ captureSubs.push(token);
return token;
},
@@ -268,9 +428,9 @@ _.extend(Meteor._LivedataConnection.prototype, {
methods: function (methods) {
var self = this;
_.each(methods, function (func, name) {
- if (self.method_handlers[name])
+ if (self._methodHandlers[name])
throw new Error("A method named '" + name + "' is already defined");
- self.method_handlers[name] = func;
+ self._methodHandlers[name] = func;
});
},
@@ -284,9 +444,13 @@ _.extend(Meteor._LivedataConnection.prototype, {
},
// @param options {Optional Object}
- // wait: Boolean - Should we block subsequent method calls on this
- // method's result having been received?
+ // wait: Boolean - Should we wait to call this until all current methods
+ // are fully finished, and block subsequent method calls
+ // until this method is fully finished?
// (does not affect methods called from within this method)
+ // onResultReceived: Function - a callback to call as soon as the method
+ // result is received. the data written by
+ // the method may not yet be in the cache!
// @param callback {Optional Function}
apply: function (name, args, options, callback) {
var self = this;
@@ -309,6 +473,16 @@ _.extend(Meteor._LivedataConnection.prototype, {
});
}
+ // Lazily allocate method ID once we know that it'll be needed.
+ var methodId = (function () {
+ var id;
+ return function () {
+ if (id === undefined)
+ id = '' + (self._nextMethodId++);
+ return id;
+ };
+ })();
+
if (Meteor.isClient) {
// If on a client, run the stub, if we have one. The stub is
// supposed to make some temporary writes to the database to
@@ -322,7 +496,10 @@ _.extend(Meteor._LivedataConnection.prototype, {
// server. The exception is if the *caller* is a stub. In that
// case, we're not going to do a RPC, so we use the return value
// of the stub as our return value.
- var stub = self.method_handlers[name];
+ var enclosing = Meteor._CurrentInvocation.get();
+ var alreadyInSimulation = enclosing && enclosing.isSimulation;
+
+ var stub = self._methodHandlers[name];
if (stub) {
var setUserId = function(userId) {
self.setUserId(userId);
@@ -330,8 +507,12 @@ _.extend(Meteor._LivedataConnection.prototype, {
var invocation = new Meteor._MethodInvocation({
isSimulation: true,
userId: self.userId(), setUserId: setUserId,
- sessionData: self.sessionData
+ sessionData: self._sessionData
});
+
+ if (!alreadyInSimulation)
+ self._saveOriginals();
+
try {
var ret = Meteor._CurrentInvocation.withValue(invocation,function () {
return stub.apply(invocation, args);
@@ -340,17 +521,18 @@ _.extend(Meteor._LivedataConnection.prototype, {
catch (e) {
var exception = e;
}
+
+ if (!alreadyInSimulation)
+ self._retrieveAndStoreOriginals(methodId());
}
// If we're in a simulation, stop and return the result we have,
// rather than going on to do an RPC. If there was no stub,
// we'll end up returning undefined.
- var enclosing = Meteor._CurrentInvocation.get();
- var isSimulation = enclosing && enclosing.isSimulation;
- if (isSimulation) {
+ if (alreadyInSimulation) {
if (callback) {
callback(exception, ret);
- return;
+ return undefined;
}
if (exception)
throw exception;
@@ -388,40 +570,37 @@ _.extend(Meteor._LivedataConnection.prototype, {
}
// Send the RPC. Note that on the client, it is important that the
- // stub have finished before we send the RPC (or at least we need
- // to guarantee that the snapshot is not restored until the stub
- // has stopped doing writes.)
- var msg = {
- msg: 'method',
- method: name,
- params: args,
- id: '' + (self.next_method_id++)
- };
+ // stub have finished before we send the RPC, so that we know we have
+ // a complete list of which local documents the stub wrote.
+ var methodInvoker = new MethodInvoker({
+ methodId: methodId(),
+ callback: callback,
+ connection: self,
+ onResultReceived: options.onResultReceived,
+ message: {
+ msg: 'method',
+ method: name,
+ params: args,
+ id: methodId()
+ }
+ });
- if (self.outstanding_wait_method) {
- self.blocked_methods.push({
- msg: msg,
- callback: callback,
- wait: options.wait
- });
+ if (options.wait) {
+ // It's a wait method! Wait methods go in their own block.
+ self._outstandingMethodBlocks.push(
+ {wait: true, methods: [methodInvoker]});
} else {
- var method_object = {
- msg: msg,
- callback: callback
- };
-
- if (options.wait)
- self.outstanding_wait_method = method_object;
- else
- self.outstanding_methods.push(method_object);
-
- self.stream.send(JSON.stringify(msg));
+ // Not a wait method. Start a new block if the previous block was a wait
+ // block, and add it to the last block of methods.
+ if (_.isEmpty(self._outstandingMethodBlocks) ||
+ _.last(self._outstandingMethodBlocks).wait)
+ self._outstandingMethodBlocks.push({wait: false, methods: []});
+ _.last(self._outstandingMethodBlocks).methods.push(methodInvoker);
}
- // Even if we are waiting on other method calls, mark this method
- // as unsatisfied so that the user never ends up seeing
- // intermediate versions of the server's datastream
- self.unsatisfied_methods[msg.id] = true;
+ // If we added it to the first block, send it out now.
+ if (self._outstandingMethodBlocks.length === 1)
+ methodInvoker.sendMessage();
// If we're using the default callback on the server,
// synchronously return the result from the remote host.
@@ -431,16 +610,58 @@ _.extend(Meteor._LivedataConnection.prototype, {
throw outcome[0];
return outcome[1];
}
+ return undefined;
+ },
+
+ // Before calling a method stub, prepare all stores to track changes and allow
+ // _retrieveAndStoreOriginals to get the original versions of changed
+ // documents.
+ _saveOriginals: function () {
+ var self = this;
+ _.each(self._stores, function (s) {
+ s.saveOriginals();
+ });
+ },
+ // Retrieves the original versions of all documents modified by the stub for
+ // method 'methodId' from all stores and saves them to _serverDocuments (keyed
+ // by document) and _documentsWrittenByStub (keyed by method ID).
+ _retrieveAndStoreOriginals: function (methodId) {
+ var self = this;
+ if (self._documentsWrittenByStub[methodId])
+ throw new Error("Duplicate methodId in _retrieveAndStoreOriginals");
+
+ var docsWritten = [];
+ _.each(self._stores, function (s, collection) {
+ var originals = s.retrieveOriginals();
+ _.each(originals, function (doc, id) {
+ docsWritten.push({collection: collection, id: id});
+ var serverDoc = Meteor._ensure(self._serverDocuments, collection, id);
+ if (serverDoc.writtenByStubs) {
+ // We're not the first stub to write this doc. Just add our method ID
+ // to the record.
+ serverDoc.writtenByStubs[methodId] = true;
+ } else {
+ // First stub! Save the original value and our method ID.
+ serverDoc.document = doc;
+ serverDoc.flushCallbacks = [];
+ serverDoc.writtenByStubs = {};
+ serverDoc.writtenByStubs[methodId] = true;
+ }
+ });
+ });
+ if (!_.isEmpty(docsWritten)) {
+ self._documentsWrittenByStub[methodId] = docsWritten;
+ }
},
status: function (/*passthrough args*/) {
var self = this;
- return self.stream.status.apply(self.stream, arguments);
+ return self._stream.status.apply(self._stream, arguments);
},
reconnect: function (/*passthrough args*/) {
var self = this;
- return self.stream.reconnect.apply(self.stream, arguments);
+ return self._stream.reconnect.apply(self._stream, arguments);
},
///
@@ -463,147 +684,304 @@ _.extend(Meteor._LivedataConnection.prototype, {
self._userIdListeners.invalidateAll();
},
- _userId: null,
- _userIdListeners: Meteor.deps && new Meteor.deps._ContextSet,
-
- // PRIVATE: called when we are up-to-date with the server. intended
- // for use only in tests. currently, you are very limited in what
- // you may do inside your callback -- in particular, don't do
- // anything that could result in another call to onQuiesce, or
- // results are undefined.
- onQuiesce: function (f) {
+ // Returns true if we are in a state after reconnect of waiting for subs to be
+ // revived or early methods to finish their data.
+ _waitingForReconnectQuiescence: function () {
var self = this;
+ return (! _.isEmpty(self._subsBeingRevived) ||
+ ! _.isEmpty(self._reconnectMethods));
+ },
- f = Meteor.bindEnvironment(f, function (e) {
- Meteor._debug("Exception in quiesce callback", e.stack);
- });
-
- for (var method_id in self.unsatisfied_methods) {
- // we are not quiesced -- wait until we are
- self.quiesce_callbacks.push(f);
- return;
- }
-
- f();
+ // Returns true if any method whose message has been sent to the server has
+ // not yet invoked its user callback.
+ _anyMethodsAreOutstanding: function () {
+ var self = this;
+ return _.any(_.pluck(self._methodInvokers, 'sentMessage'));
},
_livedata_connected: function (msg) {
var self = this;
+ // If this is a reconnect, we'll have to reset all stores.
+ if (self._lastSessionId)
+ self._resetStores = true;
+
if (typeof (msg.session) === "string") {
- var reconnected = (self.last_session_id === msg.session);
- self.last_session_id = msg.session;
+ var reconnectedToPreviousSession = (self._lastSessionId === msg.session);
+ self._lastSessionId = msg.session;
}
- if (reconnected)
- // successful reconnection -- pick up where we left off.
+ if (reconnectedToPreviousSession) {
+ // Successful reconnection -- pick up where we left off. Note that right
+ // now, this never happens: the server never connects us to a previous
+ // session, because DDP doesn't provide enough data for the server to know
+ // what messages the client has processed. We need to improve DDP to make
+ // this possible, at which point we'll probably need more code here.
return;
+ }
// Server doesn't have our data any more. Re-sync a new session.
- // Put a reset message into the pending data queue and discard any
- // previous messages (they are unimportant now).
- self.pending_data = ["reset"];
- self.queued = {};
-
- // Mark all currently ready subscriptions as 'unready'.
- var all_subs = self.subs.find({}).fetch();
- self.unready_subscriptions = {};
- _.each(all_subs, function (sub) {
- if (!self.sub_ready_callbacks[sub._id])
- self.unready_subscriptions[sub._id] = true;
+ // Forget about messages we were buffering for unknown collections. They'll
+ // be resent if still relevant.
+ self._updatesForUnknownStores = {};
+
+ // Forget about the effects of stubs. We'll be resetting all collections
+ // anyway.
+ self._documentsWrittenByStub = {};
+ self._serverDocuments = {};
+
+ // Clear _afterUpdateCallbacks.
+ self._afterUpdateCallbacks = [];
+
+ // Mark all named subscriptions which are ready (ie, we already called the
+ // ready callback) as needing to be revived.
+ // XXX We should also block reconnect quiescence until autopublish is done
+ // re-publishing to avoid flicker!
+ self._subsBeingRevived = {};
+ self._subCollection.find({}).forEach(function (sub) {
+ if (!self._subReadyCallbacks[sub._id])
+ self._subsBeingRevived[sub._id] = true;
});
- // Do not clear the database here. That happens once all the subs
- // are re-ready and we process pending_data.
+ // Arrange for "half-finished" methods to have their callbacks run, and
+ // track methods that were sent on this connection so that we don't
+ // quiesce until they are all done.
+ self._reconnectMethods = {};
+ if (self._resetStores) {
+ _.each(self._methodInvokers, function (invoker) {
+ if (invoker.gotResult()) {
+ // This method already got its result, but it didn't call its callback
+ // because its data didn't become visible. We did not resend the
+ // method RPC. We'll call its callback when we get a full quiesce,
+ // since that's as close as we'll get to "data must be visible".
+ self._afterUpdateCallbacks.push(_.bind(invoker.dataVisible, invoker));
+ } else if (invoker.sentMessage) {
+ // This method has been sent on this connection (maybe as a resend
+ // from the last connection, maybe from onReconnect, maybe just very
+ // quickly before processing the connected message).
+ //
+ // We don't need to do anything special to ensure its callbacks get
+ // called, but we'll count it as a method which is preventing
+ // reconnect quiescence. (eg, it might be a login method that was run
+ // from onReconnect, and we don't want to see flicker by seeing a
+ // logged-out state.)
+ self._reconnectMethods[invoker.methodId] = true;
+ }
+ });
+ }
+
+ self._bufferedMessagesAtReconnect = [];
+
+ // If we're not waiting on any methods or subs, we can reset the stores and
+ // call the callbacks immediately.
+ if (!self._waitingForReconnectQuiescence()) {
+ if (self._resetStores) {
+ _.each(self._stores, function (s) {
+ s.beginUpdate(0, true);
+ s.endUpdate();
+ });
+ self._resetStores = false;
+ }
+ self._runAfterUpdateCallbacks();
+ }
},
_livedata_data: function (msg) {
var self = this;
- // Add the data message to the queue
- self.pending_data.push(msg);
-
- // Process satisfied methods and subscriptions.
- // NOTE: does not fire callbacks here, that happens when
- // the data message is processed for real. This is just for
- // quiescing.
- _.each(msg.methods || [], function (method_id) {
- delete self.unsatisfied_methods[method_id];
- });
- _.each(msg.subs || [], function (sub_id) {
- delete self.unready_subscriptions[sub_id];
- });
+ // collection name -> array of messages
+ var updates = {};
- // If there are still method invocations in flight, stop
- for (var method_id in self.unsatisfied_methods)
- return;
- // If there are still uncomplete subscriptions, stop
- for (var sub_id in self.unready_subscriptions)
- return;
-
- // We have quiesced. Blow away local changes and replace
- // with authoritative changes from server.
-
- var messagesPerStore = {};
- _.each(self.pending_data, function (msg) {
- if (msg.collection && msg.id && self.stores[msg.collection]) {
- if (_.has(messagesPerStore, msg.collection))
- ++messagesPerStore[msg.collection];
- else
- messagesPerStore[msg.collection] = 1;
- }
- });
-
- _.each(self.stores, function (s, name) {
- s.beginUpdate(_.has(messagesPerStore, name) ? messagesPerStore[name] : 0);
- });
+ if (self._waitingForReconnectQuiescence()) {
+ self._bufferedMessagesAtReconnect.push(msg);
+ _.each(msg.subs || [], function (subId) {
+ delete self._subsBeingRevived[subId];
+ });
+ _.each(msg.methods || [], function (methodId) {
+ delete self._reconnectMethods[methodId];
+ });
- _.each(self.pending_data, function (msg) {
- // Reset message from reconnect. Blow away everything.
- //
- // XXX instead of reset message, we could have a flag, and pass
- // that to beginUpdate. This would be more efficient since we don't
- // have to restore a snapshot if we're just going to blow away the
- // db.
- if (msg === "reset") {
- _.each(self.stores, function (s) { s.reset(); });
+ if (self._waitingForReconnectQuiescence())
return;
- }
- if (msg.collection && msg.id) {
- var store = self.stores[msg.collection];
+ // All subscriptions that were ready before reconnect are now ready again,
+ // and all active methods at reconnect time have their data written!
+ // We'll now process and all of our buffered messages, reset all stores,
+ // and apply them all at once.
+ _.each(self._bufferedMessagesAtReconnect, function (bufferedMsg) {
+ self._processOneDataMessage(bufferedMsg, updates);
+ });
+ self._bufferedMessagesAtReconnect = [];
+ } else {
+ self._processOneDataMessage(msg, updates);
+ }
- if (!store) {
+ if (self._resetStores || !_.isEmpty(updates)) {
+ // Begin a transactional update of each store.
+ _.each(self._stores, function (s, storeName) {
+ s.beginUpdate(_.has(updates, storeName) ? updates[storeName].length : 0,
+ self._resetStores);
+ });
+ self._resetStores = false;
+
+ _.each(updates, function (updateMessages, storeName) {
+ var store = self._stores[storeName];
+ if (store) {
+ _.each(updateMessages, function (updateMessage) {
+ store.update(updateMessage);
+ });
+ } else {
// Nobody's listening for this data. Queue it up until
// someone wants it.
// XXX memory use will grow without bound if you forget to
- // create a collection.. going to have to do something about
- // that.
- if (!(msg.collection in self.queued))
- self.queued[msg.collection] = [];
- self.queued[msg.collection].push(msg);
- return;
+ // create a collection or just don't care about it... going
+ // to have to do something about that.
+ if (!_.has(self._updatesForUnknownStores, storeName))
+ self._updatesForUnknownStores[storeName] = [];
+ Array.prototype.push.apply(self._updatesForUnknownStores[storeName],
+ updateMessages);
}
+ });
- store.update(msg);
- }
+ // End update transaction.
+ _.each(self._stores, function (s) { s.endUpdate(); });
+ }
+
+ self._runAfterUpdateCallbacks();
+ },
+
+ // Call any callbacks deferred with _runWhenAllServerDocsAreFlushed whose
+ // relevant docs have been flushed, as well as dataVisible callbacks at
+ // reconnect-quiescence time.
+ _runAfterUpdateCallbacks: function () {
+ var self = this;
+ _.each(self._afterUpdateCallbacks, function (c) {
+ c();
+ });
+ self._afterUpdateCallbacks = [];
+ },
- if (msg.subs) {
- _.each(msg.subs, function (id) {
- var arr = self.sub_ready_callbacks[id];
- if (arr) _.each(arr, function (c) { c(); });
- delete self.sub_ready_callbacks[id];
+ // Process a single "data" message. Stores updates (set/unset/replace) in the
+ // "updates" object (map from collection name to array of updates). Processes
+ // "method data done" and "sub ready" declarations and schedules the relevant
+ // callbacks to occur after all currently buffered docs are written to the
+ // local cache.
+ _processOneDataMessage: function (msg, updates) {
+ var self = this;
+ // Apply writes (set/unset) from the message.
+ if (msg.collection && msg.id) {
+ var serverDoc = Meteor._get(
+ self._serverDocuments, msg.collection, msg.id);
+ if (serverDoc) {
+ // A client stub wrote this document, so we have to apply this change to
+ // the snapshot in serverDoc rather than directly to the database.
+ // First apply unset (assuming that there are any fields at all.
+ if (serverDoc.document) {
+ _.each(msg.unset, function (propname) {
+ delete serverDoc.document[propname];
+ });
+ }
+ // Now apply set.
+ _.each(msg.set, function (value, propname) {
+ if (!serverDoc.document)
+ serverDoc.document = {};
+ serverDoc.document[propname] = value;
});
+ // Now erase the document if it has become empty.
+ if (serverDoc.document &&