Merge branch 'sso' into tool-refactoring

Conflicts:
	tools/auth.js
	tools/mongo_runner.js
	tools/tests/test-bundler-npm.js
This commit is contained in:
David Glasser
2014-01-16 10:54:33 -08:00
75 changed files with 3098 additions and 1821 deletions

View File

@@ -1,3 +1,32 @@
## v.NEXT
* Hash login tokens before storing them in the database.
* Cursors with a field specifier containing `{_id: 0}` can no longer be used
with `observeChanges` or `observe`. This includes the implicit calls to these
functions that are done when returning a cursor from a publish function or
using `{{#each}}`.
* Patch Underscore to not treat plain objects (`x.constructor === Object`)
with numeric `length` fields as arrays. Among other things, this allows you
to use documents with numeric `length` fields with Mongo. #594 #1737
* Fix races when calling login and/or logoutOtherClients from multiple
tabs. #1616
* Upgrade `jquery-waypoints` package from 1.1.7 to 2.0.3. (Contains
backward-incompatible changes).
## v0.7.0.1
* Two fixes to `meteor run` Mongo startup bugs that could lead to hangs with the
message "Initializing mongo database... this may take a moment.". #1696
* Apply the Node patch to 0.10.24 as well (see the 0.7.0 section for details).
* Fix gratuitous IE7 incompatibility. #1690
## v0.7.0
This version of Meteor contains a patch for a bug in Node 0.10 which
@@ -96,6 +125,8 @@ apply the patch and will instead disable websockets.
* Increase the maximum size spiderable will return for a page from 200kB
to 5MB.
* New 'facts' package publishes internal statistics about Meteor.
* Upgraded dependencies:
* SockJS server from 0.3.7 to 0.3.8, including new faye-websocket module.
* Node from 0.10.21 to 0.10.22

View File

@@ -586,23 +586,30 @@ access the same collection using the same API.
Specifically, when you pass a `name`, here's what happens:
* On the server, a collection with that name is created on a backend
Mongo server. When you call methods on that collection on the server,
they translate directly into normal Mongo operations (after checking that
they match your [access control rules](#allow)).
* On the server (if you do not specify a `connection`), a collection with that
name is created on a backend Mongo server. When you call methods on that
collection on the server, they translate directly into normal Mongo operations
(after checking that they match your [access control rules](#allow)).
* On the client, a Minimongo instance is
created. Minimongo is essentially an in-memory, non-persistent
implementation of Mongo in pure JavaScript. It serves as a local cache
that stores just the subset of the database that this client is working
with. Queries on the client ([`find`](#find)) are served directly out of
this cache, without talking to the server.
* On the client (and on the server if you specify a `connection`), a Minimongo
instance is created. Minimongo is essentially an in-memory, non-persistent
implementation of Mongo in pure JavaScript. It serves as a local cache that
stores just the subset of the database that this client is working with. Queries
([`find`](#find)) on these collections are served directly out of this cache,
without talking to the server.
* When you write to the database on the client ([`insert`](#insert),
[`update`](#update), [`remove`](#remove)), the command is executed
immediately on the client, and, simultaneously, it's shipped up to the
server and executed there too. The `livedata` package is
responsible for this.
[`update`](#update), [`remove`](#remove)), the command is executed locally
immediately, and, simultaneously, it's sent to the server and executed
there too. This happens via [stubs](#meteor_methods), because writes are
implemented as methods.
{{#note}}
When, on the server, you write to a collection which has a specified
`connection` to another server, it sends the corresponding method to the other
server and receives the changed values back from it over DDP. Unlike on the
client, it does not execute the write locally first.
{{/note}}
If you pass `null` as the `name`, then you're creating a local
collection. It's not synchronized anywhere; it's just a local scratchpad
@@ -678,8 +685,6 @@ In this release, Minimongo has some limitations:
* `$pull` in modifiers can only accept certain kinds
of selectors.
* `$` to denote the matched array position is not
supported in modifier.
* `findAndModify`, aggregate functions, and
map/reduce aren't supported.
@@ -1319,21 +1324,32 @@ it's up to you to be sure.
Queries can specify a particular set of fields to include or exclude from the
result object.
To exclude certain fields from the result objects, the field specifier
is a dictionary whose keys are field names and whose values are `0`.
To exclude specific fields from the result objects, the field specifier is a
dictionary whose keys are field names and whose values are `0`. All unspecified
fields are included.
// Users.find({}, {fields: {password: 0, hash: 0}})
To return an object that only includes the specified field, use `1` as
To include only specific fields in the result documents, use `1` as
the value. The `_id` field is still included in the result.
// Users.find({}, {fields: {firstname: 1, lastname: 1}})
It is not possible to mix inclusion and exclusion styles (except for the cases
when `_id` is included by default or explicitly excluded). Field operators such
as `$` and `$elemMatch` are not available on the client side yet.
With one exception, it is not possible to mix inclusion and exclusion styles:
the keys must either be all 1 or all 0. The exception is that you may specify
`_id: 0` in an inclusion specifier, which will leave `_id` out of the result
object as well. However, such field specifiers can not be used with
[`observeChanges`](#observe_changes), [`observe`](#observe), cursors returned
from a [publish function](#meteor_publish), or cursors used in
`{{dstache}}#each}}` in a template. They may be used with [`fetch`](#fetch),
[`findOne`](#findone), [`forEach`](#foreach), and [`map`](#map).
More advanced example:
<a href="http://docs.mongodb.org/manual/reference/operator/projection/">Field
operators</a> such as `$` and `$elemMatch` are not available on the client side
yet.
A more advanced example:
Users.insert({ alterEgos: [{ name: "Kira", alliance: "murderer" },
{ name: "L", alliance: "police" }],
@@ -2632,9 +2648,10 @@ computation.
{{> api_box deps_nonreactive }}
Calls `func()` with `Deps.currentComputation` temporarily set to
`null`. If `func` accesses reactive data sources, these data sources
will never cause a rerun of the enclosing computation.
Calls `func` with `Deps.currentComputation` temporarily set to `null`
and returns `func`'s own return value. If `func` accesses reactive data
sources, these data sources will never cause a rerun of the enclosing
computation.
{{> api_box deps_active }}

View File

@@ -558,8 +558,9 @@ database cursor is passed to `#each`, it will wire up all of the
machinery to efficiently add and move DOM nodes as new results enter
the query.
Helpers can take arguments, and they receive the current template data
in `this`:
Helpers can take arguments, and they receive the current template context data
in `this`. Note that some block helpers change the current context (notably
`each` and `with`):
// in a JavaScript file
Template.players.leagueIs = function (league) {
@@ -825,10 +826,10 @@ To get started, run
This command will generate a fully-contained Node.js application in the form of
a tarball. To run this application, you need to provide Node.js 0.10 and a
MongoDB server. (The current release of Meteor has been tested with Node
0.10.21.) You can then run the application by invoking node, specifying the HTTP
port for the application to listen on, and the MongoDB endpoint. If you don't
already have a MongoDB server, we can recommend our friends at
[MongoHQ](http://mongohq.com).
0.10.22, and is recommended for use with 0.10.22 through 0.10.24 only.) You can
then run the application by invoking node, specifying the HTTP port for the
application to listen on, and the MongoDB endpoint. If you don't already have a
MongoDB server, we can recommend our friends at [MongoHQ](http://mongohq.com).
$ PORT=3000 MONGO_URL=mongodb://localhost:27017/myapp node bundle/main.js

View File

@@ -47,10 +47,17 @@ Meteor.startup(function () {
}
var ignore_waypoints = false;
$('body').delegate('h1, h2, h3', 'waypoint.reached', function (evt, dir) {
var lastTimeout = null;
$('h1, h2, h3').waypoint(function (evt, dir) {
if (!ignore_waypoints) {
var active = (dir === "up") ? this.prev : this;
Session.set("section", active.id);
if (active.id) {
if (lastTimeout)
Meteor.clearTimeout(lastTimeout);
lastTimeout = Meteor.setTimeout(function () {
Session.set("section", active.id);
}, 200);
}
}
});

View File

@@ -8,8 +8,8 @@ braces and parentheses. The code compiles one-to-one into the
equivalent JS, and there is no interpretation at runtime.
CoffeeScript is supported on both the client and the server. Files
ending with `.coffee` or `.litcoffee` are automatically compiled to
JavaScript.
ending with `.coffee`, `.litcoffee`, or `.coffee.md` are automatically
compiled to JavaScript.
### Namespacing and CoffeeScript

View File

@@ -18,6 +18,17 @@ if you do use underscore in your application, you should still add the
package as we will remove the default underscore in the future.
{{/warning}}
{{#warning}}
We have slightly modified the way Underscore differentiates between
objects and arrays in [collection functions](http://underscorejs.org/#each).
The original Underscore logic is to treat any object with a numeric `length`
property as an array (which helps it work properly on
[`NodeList`s](https://developer.mozilla.org/en-US/docs/Web/API/NodeList)).
In Meteor's version of Underscore, objects with a numeric `length` property
are treated as objects if they have no prototype (specifically, if
`x.constructor === Object`.
{{/warning}}
{{/better_markdown}}
</template>

View File

@@ -1,5 +1,5 @@
// While galaxy apps are on their own special meteor releases, override
// Meteor.release here.
if (Meteor.isClient) {
Meteor.release = Meteor.release ? "0.7.0" : undefined;
Meteor.release = Meteor.release ? "0.7.0.1" : undefined;
}

View File

@@ -1 +1 @@
0.7.0
0.7.0.1

View File

@@ -1 +1 @@
0.7.0
0.7.0.1

View File

@@ -1 +1 @@
0.7.0
0.7.0.1

View File

@@ -1 +1 @@
0.7.0
0.7.0.1

View File

@@ -116,8 +116,28 @@ Accounts.callLoginMethod = function (options) {
// need to show a logging-in animation.
_suppressLoggingIn: true,
userCallback: function (error) {
var storedTokenNow = storedLoginToken();
if (error) {
makeClientLoggedOut();
// If we had a login error AND the current stored token is the
// one that we tried to log in with, then declare ourselves
// logged out. If there's a token in storage but it's not the
// token that we tried to log in with, we don't know anything
// about whether that token is valid or not, so do nothing. The
// periodic localStorage poll will decide if we are logged in or
// out with this token, if it hasn't already. Of course, even
// with this check, another tab could insert a new valid token
// immediately before we clear localStorage here, which would
// lead to both tabs being logged out, but by checking the token
// in storage right now we hope to make that unlikely to happen.
//
// If there is no token in storage right now, we don't have to
// do anything; whatever code removed the token from storage was
// responsible for calling `makeClientLoggedOut()`, or the
// periodic localStorage poll will call `makeClientLoggedOut`
// eventually if another tab wiped the token from storage.
if (storedTokenNow && storedTokenNow === result.token) {
makeClientLoggedOut();
}
}
// Possibly a weird callback to call, but better than nothing if
// there is a reconnect between "login result received" and "data
@@ -195,25 +215,41 @@ Meteor.logout = function (callback) {
};
Meteor.logoutOtherClients = function (callback) {
// Our connection is going to be closed, but we don't want to call the
// onReconnect handler until the result comes back for this method, because
// the token will have been deleted on the server. Instead, wait until we get
// a new token and call the reconnect handler with that.
// XXX this is messy.
// XXX what if login gets called before the callback runs?
var origOnReconnect = Accounts.connection.onReconnect;
var userId = Meteor.userId();
Accounts.connection.onReconnect = null;
// Call the `logoutOtherClients` method. Store the login token that we get
// back and use it to log in again. The server is not supposed to close
// connections on the old token for 10 seconds, so we should have time to
// store our new token and log in with it before being disconnected. If we get
// disconnected, then we'll immediately reconnect with the new token. If for
// some reason we get disconnected before storing the new token, then the
// worst that will happen is that we'll have a flicker from trying to log in
// with the old token before storing and logging in with the new one.
Accounts.connection.apply('logoutOtherClients', [], { wait: true },
function (error, result) {
Accounts.connection.onReconnect = origOnReconnect;
if (! error)
if (error) {
callback && callback(error);
} else {
var userId = Meteor.userId();
storeLoginToken(userId, result.token, result.tokenExpires);
Accounts.connection.onReconnect();
callback && callback(error);
// If the server hasn't disconnected us yet by deleting our
// old token, then logging in now with the new valid token
// will prevent us from getting disconnected. If the server
// has already disconnected us due to our old invalid token,
// then we would have already tried and failed to login with
// the old token on reconnect, and we have to make sure a
// login method gets sent here with the new token.
Meteor.loginWithToken(result.token, function (err) {
if (err &&
storedLoginToken() &&
storedLoginToken().token === result.token) {
makeClientLoggedOut();
}
callback && callback(err);
});
}
});
};
///
/// LOGIN SERVICES
///

View File

@@ -1,3 +1,5 @@
var crypto = Npm.require('crypto');
///
/// CURRENT USER
///
@@ -60,7 +62,7 @@ loginHandlers = [];
// { user: { username: <username> }, password: <password> }, or
// { user: { email: <email> }, password: <password> }.
Accounts.createToken = function (options) {
// Try all of the registered login handlers until one of them doesn' return
// 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.
for (var i = 0; i < loginHandlers.length; ++i) {
@@ -72,12 +74,23 @@ Accounts.createToken = function (options) {
throw new Meteor.Error(400, "Unrecognized options for login request");
};
// Deletes the given loginToken from the database. This will cause all
// connections associated with the token to be closed.
// Deletes the given loginToken from the database.
//
// For new-style hashed token, this will cause all connections
// associated with the token to be closed.
//
// Any connections associated with old-style unhashed tokens will be
// in the process of becoming associated with hashed tokens and then
// they'll get closed.
Accounts.destroyToken = function (userId, loginToken) {
Meteor.users.update(userId, {
$pull: {
"services.resume.loginTokens": { "token": loginToken }
"services.resume.loginTokens": {
$or: [
{ hashedToken: loginToken },
{ token: loginToken }
]
}
}
});
};
@@ -104,7 +117,11 @@ Meteor.methods({
// currently a public API for reading the login token on a
// connection).
Meteor._noYieldsAllowed(function () {
Accounts._setLoginToken(self.connection.id, result.token);
Accounts._setLoginToken(
result.id,
self.connection,
Accounts._hashLoginToken(result.token)
);
});
self.setUserId(result.id);
}
@@ -113,7 +130,7 @@ Meteor.methods({
logout: function() {
var token = Accounts._getLoginToken(this.connection.id);
Accounts._setLoginToken(this.connection.id, null);
Accounts._setLoginToken(this.userId, this.connection, null);
if (token && this.userId)
Accounts.destroyToken(this.userId, token);
this.setUserId(null);
@@ -146,7 +163,7 @@ Meteor.methods({
"services.resume.loginTokensToDelete": tokens,
"services.resume.haveLoginTokensToDelete": true
},
$push: { "services.resume.loginTokens": newToken }
$push: { "services.resume.loginTokens": Accounts._hashStampedToken(newToken) }
});
Meteor.setTimeout(function () {
// The observe on Meteor.users will take care of closing the connections
@@ -209,7 +226,23 @@ Meteor.server.onConnection(function (connection) {
///
/// support reconnecting using a meteor login token
// token -> list of connection ids
Accounts._hashLoginToken = function (loginToken) {
var hash = crypto.createHash('sha256');
hash.update(loginToken);
return hash.digest('base64');
};
// {token, when} => {hashedToken, when}
Accounts._hashStampedToken = function (stampedToken) {
return _.extend(
_.omit(stampedToken, 'token'),
{hashedToken: Accounts._hashLoginToken(stampedToken.token)}
);
};
// hashed token -> list of connection ids
var connectionsByLoginToken = {};
// test hook
@@ -217,7 +250,8 @@ Accounts._getTokenConnections = function (token) {
return connectionsByLoginToken[token];
};
// Remove the connection from the list of open connections for the token.
// Remove the connection from the list of open connections for the connection's
// token.
var removeConnectionFromToken = function (connectionId) {
var token = Accounts._getLoginToken(connectionId);
if (token) {
@@ -234,15 +268,43 @@ Accounts._getLoginToken = function (connectionId) {
return Accounts._getAccountData(connectionId, 'loginToken');
};
Accounts._setLoginToken = function (connectionId, newToken) {
removeConnectionFromToken(connectionId);
Accounts._setAccountData(connectionId, 'loginToken', newToken);
// newToken is a hashed token.
Accounts._setLoginToken = function (userId, connection, newToken) {
removeConnectionFromToken(connection.id);
Accounts._setAccountData(connection.id, 'loginToken', newToken);
if (newToken) {
if (! _.has(connectionsByLoginToken, newToken))
connectionsByLoginToken[newToken] = [];
connectionsByLoginToken[newToken].push(connectionId);
connectionsByLoginToken[newToken].push(connection.id);
// Now that we've added the connection to the
// connectionsByLoginToken map for the token, the connection will
// be closed if the token is removed from the database. However
// at this point the token might have already been deleted, which
// wouldn't have closed the connection because it wasn't in the
// map yet.
//
// We also did need to first add the connection to the map above
// (and now remove it here if the token was deleted), because we
// could be getting a response from the database that the token
// still exists, but then it could be deleted in another fiber
// before our `findOne` call returns... and then that other fiber
// would need for the connection to be in the map for it to close
// the connection.
//
// We defer this check because there's no need for it to be on the critical
// path for login; we just need to ensure that the connection will get
// closed at some point if the token has been deleted.
Meteor.defer(function () {
if (! Meteor.users.findOne({
_id: userId,
"services.resume.loginTokens.hashedToken": newToken
})) {
removeConnectionFromToken(connection.id);
connection.close();
}
});
}
};
@@ -270,23 +332,84 @@ Accounts.registerLoginHandler(function(options) {
return undefined;
check(options.resume, String);
var user = Meteor.users.findOne({
"services.resume.loginTokens.token": ""+options.resume
});
if (!user) {
var hashedToken = Accounts._hashLoginToken(options.resume);
// First look for just the new-style hashed login token, to avoid
// sending the unhashed token to the database in a query if we don't
// need to.
var user = Meteor.users.findOne(
{"services.resume.loginTokens.hashedToken": hashedToken});
if (! user) {
// If we didn't find the hashed login token, try also looking for
// the old-style unhashed token. But we need to look for either
// the old-style token OR the new-style token, because another
// client connection logging in simultaneously might have already
// converted the token.
user = Meteor.users.findOne({
$or: [
{"services.resume.loginTokens.hashedToken": hashedToken},
{"services.resume.loginTokens.token": options.resume}
]
});
}
if (! user) {
throw new Meteor.Error(403, "You've been logged out by the server. " +
"Please login again.");
}
// Find the token, which will either be an object with fields
// {hashedToken, when} for a hashed token or {token, when} for an
// unhashed token.
var oldUnhashedStyleToken;
var token = _.find(user.services.resume.loginTokens, function (token) {
return token.token === options.resume;
return token.hashedToken === hashedToken;
});
if (token) {
oldUnhashedStyleToken = false;
} else {
token = _.find(user.services.resume.loginTokens, function (token) {
return token.token === options.resume;
});
oldUnhashedStyleToken = true;
}
var tokenExpires = Accounts._tokenExpiration(token.when);
if (new Date() >= tokenExpires)
throw new Meteor.Error(403, "Your session has expired. Please login again.");
// Update to a hashed token when an unhashed token is encountered.
if (oldUnhashedStyleToken) {
// Only add the new hashed token if the old unhashed token still
// exists (this avoids resurrecting the token if it was deleted
// after we read it). Using $addToSet avoids getting an index
// error if another client logging in simultaneously has already
// inserted the new hashed token.
Meteor.users.update(
{
_id: user._id,
"services.resume.loginTokens.token": options.resume
},
{$addToSet: {
"services.resume.loginTokens": {
"hashedToken": hashedToken,
"when": token.when
}
}}
);
// Remove the old token *after* adding the new, since otherwise
// another client trying to login between our removing the old and
// adding the new wouldn't find a token to login with.
Meteor.users.update(user._id, {
$pull: {
"services.resume.loginTokens": { "token": options.resume }
},
});
}
return {
token: options.resume,
tokenExpires: tokenExpires,
@@ -301,7 +424,6 @@ Accounts._generateStampedLoginToken = function () {
return {token: Random.id(), when: (new Date)};
};
///
/// TOKEN EXPIRATION
///
@@ -402,11 +524,12 @@ Accounts.insertUserDoc = function (options, user) {
var stampedToken = Accounts._generateStampedLoginToken();
result.token = stampedToken.token;
result.tokenExpires = Accounts._tokenExpiration(stampedToken.when);
var token = Accounts._hashStampedToken(stampedToken);
Meteor._ensure(user, 'services', 'resume');
if (_.has(user.services.resume, 'loginTokens'))
user.services.resume.loginTokens.push(stampedToken);
user.services.resume.loginTokens.push(token);
else
user.services.resume.loginTokens = [stampedToken];
user.services.resume.loginTokens = [token];
}
var fullUser;
@@ -560,7 +683,7 @@ Accounts.updateOrCreateUserFromExternalService = function(
Meteor.users.update(
user._id,
{$set: setAttrs,
$push: {'services.resume.loginTokens': stampedToken}});
$push: {'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken)}});
return {
token: stampedToken.token,
id: user._id,
@@ -714,6 +837,8 @@ Meteor.users.allow({
/// DEFAULT INDEXES ON USERS
Meteor.users._ensureIndex('username', {unique: 1, sparse: 1});
Meteor.users._ensureIndex('emails.address', {unique: 1, sparse: 1});
Meteor.users._ensureIndex('services.resume.loginTokens.hashedToken',
{unique: 1, sparse: 1});
Meteor.users._ensureIndex('services.resume.loginTokens.token',
{unique: 1, sparse: 1});
// For taking care of logoutOtherClients calls that crashed before the tokens
@@ -762,8 +887,14 @@ Meteor.startup(function () {
/// LOGGING OUT DELETED USERS
///
// When login tokens are removed from the database, close any sessions
// logged in with those tokens.
//
// Because we upgrade unhashed login tokens to hashed tokens at login
// time, sessions will only be logged in with a hashed token. Thus we
// only need to pull out hashed tokens here.
var closeTokensForUser = function (userTokens) {
closeConnectionsForTokens(_.pluck(userTokens, "token"));
closeConnectionsForTokens(_.compact(_.pluck(userTokens, "hashedToken")));
};
// Like _.difference, but uses EJSON.equals to compute which values to return.

View File

@@ -189,10 +189,10 @@ Tinytest.addAsync('accounts - expire numeric token', function (test, onComplete)
Meteor.users.update(result.id, {
$set: {
"services.resume.loginTokens": [{
token: Random.id(),
hashedToken: Random.id(),
when: date
}, {
token: Random.id(),
hashedToken: Random.id(),
when: +date
}]
}
@@ -210,6 +210,78 @@ Tinytest.addAsync('accounts - expire numeric token', function (test, onComplete)
});
// Login tokens used to be stored unhashed in the database. We want
// to make sure users can still login after upgrading.
var userWithUnhashedLoginToken = function () {
var result = Accounts.insertUserDoc({}, {username: Random.id()});
// Construct an old-style unhashed login token.
var stampedToken = Accounts._generateStampedLoginToken();
Meteor.users.update(
result.id,
{$push: {'services.resume.loginTokens': stampedToken}}
);
// Return result, as if from Accounts.insertUserDoc.
result.token = stampedToken.token;
result.tokenExpires = Accounts._tokenExpiration(stampedToken.when);
return result;
};
Tinytest.addAsync('accounts - login token', function (test, onComplete) {
// Test that we can login when the database contains a leftover
// old style unhashed login token.
var user1 = userWithUnhashedLoginToken();
var connection = DDP.connect(Meteor.absoluteUrl());
connection.call('login', {resume: user1.token});
connection.disconnect();
// Steal the unhashed token from the database and use it to login.
// This is a sanity check so that when we *can't* login with a
// stolen *hashed* token, we know it's not a problem with the test.
var user2 = userWithUnhashedLoginToken();
var stolenToken = Meteor.users.findOne(user2.id).services.resume.loginTokens[0].token;
test.isTrue(stolenToken);
connection = DDP.connect(Meteor.absoluteUrl());
connection.call('login', {resume: stolenToken});
connection.disconnect();
// Now do the same thing, this time with a stolen hashed token.
var user3 = Accounts.insertUserDoc({generateLoginToken: true}, {username: Random.id()});
stolenToken = Meteor.users.findOne(user3.id).services.resume.loginTokens[0].hashedToken;
test.isTrue(stolenToken);
connection = DDP.connect(Meteor.absoluteUrl());
// evil plan foiled
test.throws(
function () {
connection.call('login', {resume: stolenToken});
},
/You\'ve been logged out by the server/
);
connection.disconnect();
// Old style unhashed tokens are replaced by hashed tokens when
// encountered. This means that after someone logins once, the
// old unhashed token is no longer available to be stolen.
var user4 = userWithUnhashedLoginToken();
connection = DDP.connect(Meteor.absoluteUrl());
connection.call('login', {resume: user4.token});
connection.disconnect();
// The token is no longer available to be stolen.
stolenToken = Meteor.users.findOne(user4.id).services.resume.loginTokens[0].token;
test.isFalse(stolenToken);
// After the upgrade, the client can still login with their login token.
connection = DDP.connect(Meteor.absoluteUrl());
connection.call('login', {resume: user4.token});
connection.disconnect();
onComplete();
});
Tinytest.addAsync(
'accounts - connection data cleaned up',
function (test, onComplete) {

View File

@@ -115,10 +115,14 @@ var pollStoredLoginToken = function() {
// != instead of !== just to make sure undefined and null are treated the same
if (lastLoginTokenWhenPolled != currentLoginToken) {
if (currentLoginToken)
Meteor.loginWithToken(currentLoginToken); // XXX should we pass a callback here?
else
if (currentLoginToken) {
Meteor.loginWithToken(currentLoginToken, function (err) {
if (err)
makeClientLoggedOut();
});
} else {
Meteor.logout();
}
}
lastLoginTokenWhenPolled = currentLoginToken;
};

View File

@@ -95,7 +95,7 @@ Accounts.registerLoginHandler(function (options) {
throw new Meteor.Error(403, "User not found");
var stampedLoginToken = Accounts._generateStampedLoginToken();
Meteor.users.update(
userId, {$push: {'services.resume.loginTokens': stampedLoginToken}});
userId, {$push: {'services.resume.loginTokens': Accounts._hashStampedToken(stampedLoginToken)}});
return {
token: stampedLoginToken.token,
@@ -139,7 +139,7 @@ Accounts.registerLoginHandler(function (options) {
var stampedLoginToken = Accounts._generateStampedLoginToken();
Meteor.users.update(
user._id, {$push: {'services.resume.loginTokens': stampedLoginToken}});
user._id, {$push: {'services.resume.loginTokens': Accounts._hashStampedToken(stampedLoginToken)}});
return {
token: stampedLoginToken.token,
@@ -314,13 +314,14 @@ Meteor.methods({resetPassword: function (token, newVerifier) {
throw new Meteor.Error(403, "Token has invalid email address");
var stampedLoginToken = Accounts._generateStampedLoginToken();
var newHashedToken = Accounts._hashStampedToken(stampedLoginToken);
// NOTE: We're about to invalidate tokens on the user, who we might be
// logged in as. Make sure to avoid logging ourselves out if this
// happens. But also make sure not to leave the connection in a state
// of having a bad token set if things fail.
var oldToken = Accounts._getLoginToken(this.connection.id);
Accounts._setLoginToken(this.connection.id, null);
Accounts._setLoginToken(user._id, this.connection, null);
try {
// Update the user record by:
@@ -331,17 +332,17 @@ Meteor.methods({resetPassword: function (token, newVerifier) {
// - Verifying their email, since they got the password reset via email.
Meteor.users.update({_id: user._id, 'emails.address': email}, {
$set: {'services.password.srp': newVerifier,
'services.resume.loginTokens': [stampedLoginToken],
'services.resume.loginTokens': [newHashedToken],
'emails.$.verified': true},
$unset: {'services.password.reset': 1}
});
} catch (err) {
// update failed somehow. reset to old token.
Accounts._setLoginToken(this.connection.id, oldToken);
Accounts._setLoginToken(user._id, this.connection, oldToken);
throw err;
}
Accounts._setLoginToken(this.connection.id, stampedLoginToken.token);
Accounts._setLoginToken(user._id, this.connection, newHashedToken.hashedToken);
this.setUserId(user._id);
return {
@@ -421,6 +422,7 @@ Meteor.methods({verifyEmail: function (token) {
// Log the user in with a new login token.
var stampedLoginToken = Accounts._generateStampedLoginToken();
var hashedToken = Accounts._hashStampedToken(stampedLoginToken);
// By including the address in the query, we can use 'emails.$' in the
// modifier to get a reference to the specific object in the emails
@@ -432,10 +434,10 @@ Meteor.methods({verifyEmail: function (token) {
'emails.address': tokenRecord.address},
{$set: {'emails.$.verified': true},
$pull: {'services.email.verificationTokens': {token: token}},
$push: {'services.resume.loginTokens': stampedLoginToken}});
$push: {'services.resume.loginTokens': hashedToken}});
this.setUserId(user._id);
Accounts._setLoginToken(this.connection.id, stampedLoginToken.token);
Accounts._setLoginToken(user._id, this.connection, hashedToken.hashedToken);
return {
token: stampedLoginToken.token,
tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when),
@@ -514,7 +516,11 @@ Meteor.methods({createUser: function (options) {
// client gets logged in as the new user afterwards.
this.setUserId(result.id);
Accounts._setLoginToken(this.connection.id, result.token);
Accounts._setLoginToken(
result.id,
this.connection,
Accounts._hashLoginToken(result.token)
);
return result;
}});

View File

@@ -1,5 +1,13 @@
Accounts._noConnectionCloseDelayForTest = true;
if (Meteor.isServer) {
Meteor.methods({
getUserId: function () {
return this.userId;
}
});
}
if (Meteor.isClient) (function () {
// XXX note, only one test can do login/logout things at once! for
@@ -344,7 +352,23 @@ if (Meteor.isClient) (function () {
loggedInAs(this.username, test, expect));
},
function(test, expect) {
function (test, expect) {
// we can't login with an invalid token
var expectLoginError = expect(function (err) {
test.isTrue(err);
});
Meteor.loginWithToken('invalid', expectLoginError);
},
function (test, expect) {
// we can login with a valid token
var expectLoginOK = expect(function (err) {
test.isFalse(err);
});
Meteor.loginWithToken(Accounts._storedLoginToken(), expectLoginOK);
},
function (test, expect) {
// test logging out invalidates our token
var expectLoginError = expect(function (err) {
test.isTrue(err);
@@ -356,7 +380,7 @@ if (Meteor.isClient) (function () {
});
},
function(test, expect) {
function (test, expect) {
var self = this;
// Test that login tokens get expired. We should get logged out when a
// token expires, and not be able to log in again with the same token.
@@ -388,10 +412,10 @@ if (Meteor.isClient) (function () {
var self = this;
// copied from livedata/client_convenience.js
var ddpUrl = '/';
self.ddpUrl = '/';
if (typeof __meteor_runtime_config__ !== "undefined") {
if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL)
ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL;
self.ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL;
}
// XXX can we get the url from the existing connection somehow
// instead?
@@ -400,7 +424,7 @@ if (Meteor.isClient) (function () {
// connection while leaving Accounts.connection logged in.
var token;
var userId;
self.secondConn = DDP.connect(ddpUrl);
self.secondConn = DDP.connect(self.ddpUrl);
var expectLoginError = expect(function (err) {
test.isTrue(err);
@@ -461,6 +485,41 @@ if (Meteor.isClient) (function () {
);
},
logoutStep,
function (test, expect) {
var self = this;
// Test that, when we call logoutOtherClients, if the server disconnects
// us before the logoutOtherClients callback runs, then we still end up
// logged in.
var expectServerLoggedIn = expect(function (err, result) {
test.isFalse(err);
test.isTrue(Meteor.userId());
test.equal(result, Meteor.userId());
});
Meteor.loginWithPassword(
self.username,
self.password,
expect(function (err) {
test.isFalse(err);
test.isTrue(Meteor.userId());
// The test is only useful if things interleave in the following order:
// - logoutOtherClients runs on the server
// - onReconnect fires and sends a login method with the old token,
// which results in an error
// - logoutOtherClients callback runs and stores the new token and
// logs in with it
// In practice they seem to interleave this way, but I'm not sure how
// to make sure that they do.
Meteor.logoutOtherClients(function (err) {
test.isFalse(err);
Meteor.call("getUserId", expectServerLoggedIn);
});
})
);
},
logoutStep,
function (test, expect) {
var self = this;
// Test that deleting a user logs out that user's connections.
@@ -568,7 +627,6 @@ if (Meteor.isServer) (function () {
test.isTrue(result);
var token = Accounts._getAccountData(serverConn.id, 'loginToken');
test.isTrue(token);
test.equal(result.token, token);
test.isTrue(_.contains(
Accounts._getTokenConnections(token), serverConn.id));
clientConn.disconnect();

View File

@@ -58,11 +58,10 @@ window.applicationCache.addEventListener('noupdate', cacheIsNowUpToDate, false);
window.applicationCache.addEventListener('obsolete', (function() {
if (reloadRetry) {
cacheIsNowUpToDate();
}
else {
} else {
appcacheUpdated = true;
Reload._reload();
}
}), false);
} // if window.applicationCache
} // if window.applicationCache

View File

@@ -44,8 +44,7 @@ Meteor.AppCache = {
_.each(value, function (urlPrefix) {
RoutePolicy.declare(urlPrefix, 'static-online');
});
}
else {
} else {
throw new Error('Invalid AppCache config option: ' + option);
}
});

View File

@@ -149,15 +149,50 @@ AppConfig.configurePackage = function (packageName, configure) {
};
};
AppConfig.configureService = function (serviceName, configure) {
AppConfig.configureService = function (serviceName, version, configure) {
// Collect all the endpoints for this service, from both old- and new-format
// documents, and call the `configure` callback with all the service endpoints
// that we know about.
var callConfigure = function (doc) {
var serviceDocs = Services.find({
name: serviceName,
version: version
});
var endpoints = [];
serviceDocs.forEach(function (serviceDoc) {
if (serviceDoc.providers) {
_.each(serviceDoc.providers, function (endpoint, app) {
endpoints.push(endpoint);
});
} else {
endpoints.push(serviceDoc.endpoint);
}
});
configure(endpoints);
};
if (ultra) {
// there's a Meteor.startup() that produces the various collections, make
// sure it runs first before we continue.
collectionFuture.wait();
ultra.subscribe('servicesByName', serviceName);
return Services.find({name: serviceName}).observe({
added: configure,
changed: configure
// First try to subscribe to the new format service registrations; if that
// sub doesn't exist, then ultraworld hasn't updated to the new format yet,
// so try the old format `servicesByName` sub instead.
ultra.subscribe('services', serviceName, version, {
onError: function (err) {
if (err.error === 404) {
ultra.subscribe('servicesByName', serviceName);
}
}
});
return Services.find({
name: serviceName,
version: version
}).observe({
added: callConfigure,
changed: callConfigure,
removed: callConfigure
});
}

View File

@@ -44,9 +44,7 @@ Autoupdate.newClientAvailable = function () {
// XXX Livedata exporting this via DDP is a hack. See
// packages/livedata/livedata_common.js
var retry = new DDP._Retry({
var retry = new Retry({
// Unlike the stream reconnect use of Retry, which we want to be instant
// in normal operation, this is a wacky failure. We don't want to retry
// right away, we can start slowly.

View File

@@ -5,7 +5,7 @@ Package.describe({
Package.on_use(function (api) {
api.use('webapp', 'server');
api.use('deps', 'client');
api.use(['deps', 'retry'], 'client');
api.use(['livedata', 'mongo-livedata'], ['client', 'server']);
api.use('deps', 'client');
api.use('reload', 'client', {weak: true});

View File

@@ -45,6 +45,7 @@ _.extend(Ctl, {
Ctl.findGalaxy(), 'getAppConfiguration', [Ctl.myAppName()]);
if (typeof admin == 'undefined')
admin = appConfig.admin;
admin = !!admin;
var jobId = null;
var rootUrl = Ctl.rootUrl;
@@ -65,13 +66,15 @@ _.extend(Ctl, {
});
// XXX args? env?
var env = {
ROOT_URL: rootUrl,
METEOR_SETTINGS: appConfig.settings || appConfig.METEOR_SETTINGS
};
if (admin)
env.ADMIN_APP = 'true';
jobId = Ctl.prettyCall(Ctl.findGalaxy(), 'run', [Ctl.myAppName(), program, {
exitPolicy: 'restart',
env: {
ROOT_URL: rootUrl,
METEOR_SETTINGS: appConfig.settings || appConfig.METEOR_SETTINGS,
ADMIN_APP: admin
},
env: env,
ports: {
"main": {
bindEnv: "PORT",
@@ -139,27 +142,30 @@ _.extend(Ctl, {
updateProxyActiveTags: function (tags) {
var proxy;
var proxyTagSwitchFuture = new Future;
AppConfig.configureService('proxy', function (proxyService) {
try {
proxy = Follower.connect(proxyService.providers.proxy, {
group: "proxy"
});
proxy.call('updateTags', Ctl.myAppName(), tags);
proxy.disconnect();
if (!proxyTagSwitchFuture.isResolved())
proxyTagSwitchFuture['return']();
} catch (e) {
if (!proxyTagSwitchFuture.isResolved())
proxyTagSwitchFuture['throw'](e);
AppConfig.configureService('proxy', 'pre0', function (proxyService) {
if (proxyService && ! _.isEmpty(proxyService)) {
try {
proxy = Follower.connect(proxyService, {
group: "proxy"
});
proxy.call('updateTags', Ctl.myAppName(), tags);
proxy.disconnect();
if (!proxyTagSwitchFuture.isResolved())
proxyTagSwitchFuture['return']();
} catch (e) {
if (!proxyTagSwitchFuture.isResolved())
proxyTagSwitchFuture['throw'](e);
}
}
});
var proxyTimeout = Meteor.setTimeout(function () {
if (!proxyTagSwitchFuture.isResolved())
proxyTagSwitchFuture['throw'](
new Error("timed out looking for a proxy " +
"or trying to change tags on it " +
proxy.status().status));
new Error("Timed out looking for a proxy " +
"or trying to change tags on it. Status: " +
(proxy ? proxy.status().status : "no connection"))
);
}, 10*1000);
proxyTagSwitchFuture.wait();
Meteor.clearTimeout(proxyTimeout);

View File

@@ -98,7 +98,9 @@ _.extend(Deps.Computation.prototype, {
var g = function () {
Deps.nonreactive(function () {
f(self);
return Meteor._noYieldsAllowed(function () {
f(self);
});
});
};
@@ -284,7 +286,9 @@ _.extend(Deps, {
throw new Error('Deps.autorun requires a function argument');
constructingComputation = true;
var c = new Deps.Computation(f, Deps.currentComputation);
var c = new Deps.Computation(function (c) {
Meteor._noYieldsAllowed(_.bind(f, this, c));
}, Deps.currentComputation);
if (Deps.active)
Deps.onInvalidate(function () {

View File

@@ -34,6 +34,6 @@ Facebook.requestCredential = function (options, credentialRequestCompleteCallbac
Oauth.showPopup(
loginUrl,
_.bind(credentialRequestCompleteCallack, null, credentialToken)
_.bind(credentialRequestCompleteCallback, null, credentialToken)
);
};

View File

@@ -104,7 +104,7 @@ Follower = {
url: url
}]);
} else {
conn = DDP.connect(url);
conn = DDP.connect(url, options);
prevReconnect = conn.reconnect;
prevDisconnect = conn.disconnect;
prevApply = conn.apply;

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
"version": "0.7.0",
"dependencies": {
"websocket-driver": {
"version": "0.3.1"
"version": "0.3.2"
}
}
}

View File

@@ -115,8 +115,3 @@ stringifyDDP = function (msg) {
// state in the DDP session. Meteor.setTimeout and friends clear
// it. We can probably find a better way to factor this.
DDP._CurrentInvocation = new Meteor.EnvironmentVariable;
// This is private and a hack. It is used by autoupdate_client. We
// should refactor. Maybe a separate 'exponential-backoff' package?
DDP._Retry = Retry;

View File

@@ -545,6 +545,7 @@ _.extend(Connection.prototype, {
var self = this;
var f = new Future();
var ready = false;
var handle;
args = args || [];
args.push({
onReady: function () {
@@ -559,8 +560,9 @@ _.extend(Connection.prototype, {
}
});
self.subscribe.apply(self, [name].concat(args));
handle = self.subscribe.apply(self, [name].concat(args));
f.wait();
return handle;
},
methods: function (methods) {

View File

@@ -24,7 +24,6 @@ Tinytest.addAsync(
}
);
Tinytest.addAsync(
"livedata server - connectionHandle.close()",
function (test, onComplete) {

View File

@@ -6,7 +6,8 @@ Package.describe({
Npm.depends({sockjs: "0.3.8", websocket: "1.0.8"});
Package.on_use(function (api) {
api.use(['check', 'random', 'ejson', 'json', 'underscore', 'deps', 'logging'],
api.use(['check', 'random', 'ejson', 'json', 'underscore', 'deps',
'logging', 'retry'],
['client', 'server']);
// It is OK to use this package on a server architecture without making a
@@ -33,7 +34,6 @@ Package.on_use(function (api) {
// Transport
api.use('reload', 'client', {weak: true});
api.add_files('common.js');
api.add_files('retry.js', ['client', 'server']);
api.add_files(['sockjs-0.3.4.js', 'stream_client_sockjs.js'], 'client');
api.add_files('stream_client_nodejs.js', 'server');
api.add_files('stream_client_common.js', ['client', 'server']);

View File

@@ -331,8 +331,7 @@ LiveRange.prototype.visit = function(visitRange, visitNode) {
recurse(rangeStart, rangeEnd, startIndex+1);
visitRange(false, range);
n = rangeEnd;
}
else {
} else {
// bare node
if (visitNode(true, n) !== false && n.firstChild)
recurse(n.firstChild, n.lastChild);

View File

@@ -1,20 +1,37 @@
// This is not an ideal name, but we can change it later.
// Meteor._localStorage is not an ideal name, but we can change it later.
if (window.localStorage) {
Meteor._localStorage = {
getItem: function (key) {
return window.localStorage.getItem(key);
},
setItem: function (key, value) {
window.localStorage.setItem(key, value);
},
removeItem: function (key) {
window.localStorage.removeItem(key);
}
};
// Let's test to make sure that localStorage actually works. For example, in
// Safari with private browsing on, window.localStorage exists but actually
// trying to use it throws.
var key = '_localstorage_test_' + Random.id();
var retrieved;
try {
window.localStorage.setItem(key, key);
retrieved = window.localStorage.getItem(key);
window.localStorage.removeItem(key);
} catch (e) {
// ... ignore
}
if (key === retrieved) {
Meteor._localStorage = {
getItem: function (key) {
return window.localStorage.getItem(key);
},
setItem: function (key, value) {
window.localStorage.setItem(key, value);
},
removeItem: function (key) {
window.localStorage.removeItem(key);
}
};
}
}
// XXX eliminate dependency on jQuery, detect browsers ourselves
else if ($.browser.msie) { // If we are on IE, which support userData
// Else, if we are on IE, which support userData
if (!Meteor._localStorage && $.browser.msie) {
var userdata = document.createElement('span'); // could be anything
userdata.style.behavior = 'url("#default#userData")';
userdata.id = 'localstorage-helper';
@@ -40,7 +57,9 @@ else if ($.browser.msie) { // If we are on IE, which support userData
return userdata.getAttribute(key);
}
};
} else {
}
if (!Meteor._localStorage) {
Meteor._debug(
"You are running a browser with no localStorage or userData "
+ "support. Logging in from one tab will not cause another "

View File

@@ -5,6 +5,7 @@ Package.describe({
Package.on_use(function (api) {
api.use('jquery', 'client'); // XXX only used for browser detection. remove.
api.use('random', 'client');
api.add_files('localstorage.js', 'client');
});

View File

@@ -1,13 +1,16 @@
// Temporary workaround for https://github.com/joyent/node/issues/6506
// Our fix involves replicating a bunch of files in order to
//
if (process.version !== 'v0.10.22' && process.version !== 'v0.10.23') {
// Our fix involves replicating a bunch of functions in order to change
// a single line.
var PATCH_VERSIONS = ['v0.10.22', 'v0.10.23', 'v0.10.24'];
if (!_.contains(PATCH_VERSIONS, process.version)) {
if (!process.env.DISABLE_WEBSOCKETS) {
console.error("This version of Meteor contains a patch for a bug in Node v0.10.");
console.error("The patch is against only versions 0.10.22 and 0.10.23.");
console.error("The patch is against only versions 0.10.22 through 0.10.24.");
console.error("You are using version " + process.version + " instead, so we cannot apply the patch.");
console.error("To mitigate the most common effect of the bug, websockets will be disabled.");
console.error("To enable websockets, use Node v0.10.22 or .23, or upgrade to a later version of Meteor (if available).");
console.error("To enable websockets, use Node v0.10.22 through v0.10.24, or upgrade to a later version of Meteor (if available).");
process.env.DISABLE_WEBSOCKETS = 't';
}
} else {

View File

@@ -4,21 +4,34 @@ Meteor = {
};
Meteor.settings = {};
if (process.env.METEOR_SETTINGS) {
if (process.env.APP_CONFIG) {
// put settings from the app configuration in the settings. Don't depend on
// the Galaxy package for now, to avoid silly loops.
try {
var appConfig = JSON.parse(process.env.APP_CONFIG);
if (!appConfig.settings) {
Meteor.settings = {};
} else if (typeof appConfig.settings === "string") {
Meteor.settings = JSON.parse(appConfig.settings);
} else {
// Old versions of Galaxy may store settings in MongoDB as objects. Newer
// versions store it as strings (so that we aren't restricted to
// MongoDB-compatible objects). This line makes it work on older Galaxies.
// XXX delete this eventually
Meteor.settings = appConfig.settings;
}
} catch (e) {
throw new Error("Settings from app config are not valid JSON");
}
} else if (process.env.METEOR_SETTINGS) {
try {
Meteor.settings = JSON.parse(process.env.METEOR_SETTINGS);
} catch (e) {
throw new Error("Settings are not valid JSON");
}
} else if ( process.env.APP_CONFIG) {
// put settings from the app configuration in the settings. Don't depend on
// the Galaxy package for now, to avoid silly loops.
try {
Meteor.settings = JSON.parse(process.env.APP_CONFIG).settings || {};
} catch (e) {
throw new Error("Settings are not valid JSON");
}
}
// Push a subset of settings to the client.
if (Meteor.settings && Meteor.settings.public &&
typeof __meteor_runtime_config__ === "object") {

View File

@@ -20,7 +20,7 @@ Meteor.absoluteUrl = function (path, options) {
if (path)
url += path;
// turn http to http if secure option is set, and we're not talking
// turn http to https if secure option is set, and we're not talking
// to localhost.
if (options.secure &&
/^http:/.test(url) && // url starts with 'http:'

View File

@@ -26,6 +26,9 @@ MeteorId.requestCredential = function (credentialRequestCompleteCallback) {
Oauth.showPopup(
loginUrl,
_.bind(credentialRequestCompleteCallback, null, credentialToken),
{ height: 406 }
{
width: 430,
height: 406
}
);
};

View File

@@ -1,2 +1,2 @@
// XXX fill me in!
METEORID_URL = "";
METEORID_URL = "http://10.0.2.2:3000";

View File

@@ -3,7 +3,7 @@
First, you'll need to get a MeteorId Client ID.
Set Authorized Redirect URIs to:
<span class="url">
{{siteUrl}}_oauth/meteor?close
{{siteUrl}}_oauth/meteorId?close
</span>
</p>
</template>

View File

@@ -5,10 +5,22 @@ In update, $pull can't take a selector like {$gt: 3} (but it can take
can be used, but selectors that are intended to match non-document
values won't work.)
In update, we don't support '$' to indicate the matched array object
as in {$set: {'a.$.x': 12}}.
Sort does not correctly implement the Mongo behavior where only array entries
that match the query's selector are used as sort keys. Specifically, when
sorting a document with sort specifier {a:1}, the document {a: [5, 10]} will
usually have sort key 5 (the minimum is used for ascending sorts and the maximum
for descending sorts). But if the query's selector is {a: {$gt: 7}}, then Mongo
will actually use 10 as the sort key because 5 does not match.
Sort does not support subkeys. You can sort on 'a', but not 'a.b'.
Unsupported selectors:
- $elemMatch inside $all
- geoqueries other than $near ($nearSphere, $geoIntersects, $geoWithin)
In MongoDB, a query with $near that matches a document which has multiple points
matching the key (eg, `c.find({$near: 'a'})` with the document
`{a: [[1,1], [2, 2]]}` can return the document multiple times (perhaps not
even consecutively). In minimongo, queries never return a document multiple
times.
## ON TYPES ##
@@ -29,9 +41,6 @@ integer type, and we don't support the integer type yet.)
## API ##
find() doesn't support retrieving a subset of fields. It always
returns the whole doc.
find() doesn't support the min and max parameters.
findAndModify isn't supported.
@@ -43,9 +52,6 @@ update() should have a clear stance on atomicity (both in terms of
multiple ops on a single document, and on multi-document update mode.)
It just hasn't been looked at/thought about yet.
upsert combined with $-operators might work, but hasn't actually been
looked at or tested.
In general, the API needs tests, especially update. (On the other
hand, the underlying selector and mutator code is quite well tested.)
@@ -53,13 +59,9 @@ hand, the underlying selector and mutator code is quite well tested.)
We ignore the 'x' and 's' flags on regular expressions.
We don't do as much type checking as we could, especially in
selectors. If pass in something that's weirdly formed, you'll probably
just get a random exception or error.
"Natural order" isn't very well defined.
We don't support capped collections.
No performance optimization has been done. In particular, there are no
Little performance optimization has been done. In particular, there are no
indexes.

View File

@@ -0,0 +1,39 @@
// Like _.isArray, but doesn't regard polyfilled Uint8Arrays on old browsers as
// arrays.
// XXX maybe this should be EJSON.isArray
isArray = function (x) {
return _.isArray(x) && !EJSON.isBinary(x);
};
// XXX maybe this should be EJSON.isObject, though EJSON doesn't know about
// RegExp
// XXX note that _type(undefined) === 3!!!!
isPlainObject = function (x) {
return x && LocalCollection._f._type(x) === 3;
};
isIndexable = function (x) {
return isArray(x) || isPlainObject(x);
};
isOperatorObject = function (valueSelector) {
if (!isPlainObject(valueSelector))
return false;
var theseAreOperators = undefined;
_.each(valueSelector, function (value, selKey) {
var thisIsOperator = selKey.substr(0, 1) === '$';
if (theseAreOperators === undefined) {
theseAreOperators = thisIsOperator;
} else if (theseAreOperators !== thisIsOperator) {
throw new Error("Inconsistent operator: " + valueSelector);
}
});
return !!theseAreOperators; // {} has no operators
};
// string can be converted to integer
isNumericKey = function (s) {
return /^[0-9]+$/.test(s);
};

View File

@@ -20,7 +20,7 @@ LocalCollection = function (name) {
// results: array (ordered) or object (unordered) of current results
// results_snapshot: snapshot of results. null if not paused.
// cursor: Cursor object for the query.
// selector_f, sort_f, (callbacks): functions
// selector, sorter, (callbacks): functions
this.queries = {};
// null if not saving originals; a map from id to original document value if
@@ -31,6 +31,8 @@ LocalCollection = function (name) {
this.paused = false;
};
Minimongo = {};
// Object exported only for unit testing.
// Use it to export private functions to test in Tinytest.
MinimongoTest = {};
@@ -89,18 +91,13 @@ LocalCollection.Cursor = function (collection, selector, options) {
if (LocalCollection._selectorIsId(selector)) {
// stash for fast path
self.selector_id = LocalCollection._idStringify(selector);
self.selector_f = LocalCollection._compileSelector(selector, self);
self.sort_f = undefined;
self.matcher = new Minimongo.Matcher(selector, self);
self.sorter = undefined;
} else {
// MongoDB throws different errors on different branching operators
// containing $near
if (isGeoQuerySpecial(selector))
throw new Error("$near can't be inside $or/$and/$nor/$not");
self.selector_id = undefined;
self.selector_f = LocalCollection._compileSelector(selector, self);
self.sort_f = (isGeoQuery(selector) || options.sort) ?
LocalCollection._compileSort(options.sort || [], self) : null;
self.matcher = new Minimongo.Matcher(selector, self);
self.sorter = (self.matcher.hasGeoQuery() || options.sort) ?
new Sorter(options.sort || []) : null;
}
self.skip = options.skip;
self.limit = options.limit;
@@ -273,10 +270,14 @@ _.extend(LocalCollection.Cursor.prototype, {
if (!options._allow_unordered && !ordered && (self.skip || self.limit))
throw new Error("must use ordered observe with skip or limit");
// XXX merge this object w/ "this" Cursor. they're the same.
if (self.fields && (self.fields._id === 0 || self.fields._id === false))
throw Error("You may not observe a cursor with {fields: {_id: 0}}");
var query = {
selector_f: self.selector_f, // not fast pathed
sort_f: ordered && self.sort_f,
matcher: self.matcher, // not fast pathed
sorter: ordered && self.sorter,
distances: (
self.matcher.hasGeoQuery() && ordered && new LocalCollection._IdMap),
results_snapshot: null,
ordered: ordered,
cursor: self,
@@ -292,7 +293,7 @@ _.extend(LocalCollection.Cursor.prototype, {
qid = self.collection.next_qid++;
self.collection.queries[qid] = query;
}
query.results = self._getRawObjects(ordered);
query.results = self._getRawObjects(ordered, query.distances);
if (self.collection.paused)
query.results_snapshot = (ordered ? [] : {});
@@ -371,13 +372,21 @@ _.extend(LocalCollection.Cursor.prototype, {
// Returns a collection of matching objects, but doesn't deep copy them.
//
// If ordered is set, returns a sorted array, respecting sort_f, skip, and limit
// properties of the query. if sort_f is falsey, no sort -- you get the natural
// If ordered is set, returns a sorted array, respecting sorter, skip, and limit
// properties of the query. if sorter is falsey, no sort -- you get the natural
// order.
//
// If ordered is not set, returns an object mapping from ID to doc (sort_f, skip
// If ordered is not set, returns an object mapping from ID to doc (sorter, skip
// and limit should not be set).
LocalCollection.Cursor.prototype._getRawObjects = function (ordered) {
//
// If ordered is set and this cursor is a $near geoquery, then this function
// will use an _IdMap to track each distance from the $near argument point in
// order to use it as a sort key. If an _IdMap is passed in the 'distances'
// argument, this function will clear it and use it for this purpose (otherwise
// it will just create its own _IdMap). The observeChanges implementation uses
// this to remember the distances after this function returns.
LocalCollection.Cursor.prototype._getRawObjects = function (ordered,
distances) {
var self = this;
var results = ordered ? [] : {};
@@ -401,16 +410,32 @@ LocalCollection.Cursor.prototype._getRawObjects = function (ordered) {
}
// slow path for arbitrary selector, sort, skip, limit
for (var id in self.collection.docs) {
var doc = self.collection.docs[id];
if (self.selector_f(doc)) {
if (ordered)
// in the observeChanges case, distances is actually part of the "query" (ie,
// live results set) object. in other cases, distances is only used inside
// this function.
if (self.matcher.hasGeoQuery() && ordered) {
if (distances)
distances.clear();
else
distances = new LocalCollection._IdMap();
}
for (var idStringified in self.collection.docs) {
var doc = self.collection.docs[idStringified];
var id = LocalCollection._idParse(idStringified); // XXX use more idmaps
var matchResult = self.matcher.documentMatches(doc);
if (matchResult.result) {
if (ordered) {
results.push(doc);
else
results[id] = doc;
if (distances && matchResult.distance !== undefined)
distances.set(id, matchResult.distance);
} else {
results[idStringified] = doc;
}
}
// Fast path for limited unsorted queries.
if (self.limit && !self.skip && !self.sort_f &&
if (self.limit && !self.skip && !self.sorter &&
results.length === self.limit)
return results;
}
@@ -418,8 +443,10 @@ LocalCollection.Cursor.prototype._getRawObjects = function (ordered) {
if (!ordered)
return results;
if (self.sort_f)
results.sort(self.sort_f);
if (self.sorter) {
var comparator = self.sorter.getComparator({distances: distances});
results.sort(comparator);
}
var idx_start = self.skip || 0;
var idx_end = self.limit ? (self.limit + idx_start) : results.length;
@@ -477,7 +504,10 @@ LocalCollection.prototype.insert = function (doc, callback) {
// trigger live queries that match
for (var qid in self.queries) {
var query = self.queries[qid];
if (query.selector_f(doc)) {
var matchResult = query.matcher.documentMatches(doc);
if (matchResult.result) {
if (query.distances && matchResult.distance !== undefined)
query.distances.set(doc._id, matchResult.distance);
if (query.cursor.skip || query.cursor.limit)
queriesToRecompute.push(qid);
else
@@ -505,23 +535,24 @@ LocalCollection.prototype.remove = function (selector, callback) {
var remove = [];
var queriesToRecompute = [];
var selector_f = LocalCollection._compileSelector(selector, self);
var matcher = new Minimongo.Matcher(selector, self);
// Avoid O(n) for "remove a single doc by ID".
var specificIds = LocalCollection._idsMatchedBySelector(selector);
if (specificIds) {
_.each(specificIds, function (id) {
var strId = LocalCollection._idStringify(id);
// We still have to run selector_f, in case it's something like
// We still have to run matcher, in case it's something like
// {_id: "X", a: 42}
if (_.has(self.docs, strId) && selector_f(self.docs[strId]))
if (_.has(self.docs, strId)
&& matcher.documentMatches(self.docs[strId]).result)
remove.push(strId);
});
} else {
for (var id in self.docs) {
var doc = self.docs[id];
if (selector_f(doc)) {
remove.push(id);
for (var strId in self.docs) {
var doc = self.docs[strId];
if (matcher.documentMatches(doc).result) {
remove.push(strId);
}
}
}
@@ -531,7 +562,7 @@ LocalCollection.prototype.remove = function (selector, callback) {
var removeId = remove[i];
var removeDoc = self.docs[removeId];
_.each(self.queries, function (query, qid) {
if (query.selector_f(removeDoc)) {
if (query.matcher.documentMatches(removeDoc).result) {
if (query.cursor.skip || query.cursor.limit)
queriesToRecompute.push(qid);
else
@@ -545,8 +576,10 @@ LocalCollection.prototype.remove = function (selector, callback) {
// run live query callbacks _after_ we've removed the documents.
_.each(queryRemove, function (remove) {
var query = self.queries[remove.qid];
if (query)
if (query) {
query.distances && query.distances.remove(remove.doc._id);
LocalCollection._removeFromResults(query, remove.doc);
}
});
_.each(queriesToRecompute, function (qid) {
var query = self.queries[qid];
@@ -572,7 +605,7 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) {
}
if (!options) options = {};
var selector_f = LocalCollection._compileSelector(selector, self);
var matcher = new Minimongo.Matcher(selector, self);
// Save the original results of any query that we might need to
// _recomputeResults on, because _modifyAndNotify will mutate the objects in
@@ -590,10 +623,11 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) {
for (var id in self.docs) {
var doc = self.docs[id];
if (selector_f(doc)) {
var queryResult = matcher.documentMatches(doc);
if (queryResult.result) {
// XXX Should we save the original even if mod ends up being a no-op?
self._saveOriginal(id, doc);
self._modifyAndNotify(doc, mod, recomputeQids);
self._modifyAndNotify(doc, mod, recomputeQids, queryResult.arrayIndex);
++updateCount;
if (!options.multi)
break;
@@ -614,7 +648,7 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) {
var insertedId;
if (updateCount === 0 && options.upsert) {
var newDoc = LocalCollection._removeDollarOperators(selector);
LocalCollection._modify(newDoc, mod, true);
LocalCollection._modify(newDoc, mod, {isInsert: true});
if (! newDoc._id && options.insertedId)
newDoc._id = options.insertedId;
insertedId = self.insert(newDoc);
@@ -658,14 +692,14 @@ LocalCollection.prototype.upsert = function (selector, mod, options, callback) {
};
LocalCollection.prototype._modifyAndNotify = function (
doc, mod, recomputeQids) {
doc, mod, recomputeQids, arrayIndex) {
var self = this;
var matched_before = {};
for (var qid in self.queries) {
var query = self.queries[qid];
if (query.ordered) {
matched_before[qid] = query.selector_f(doc);
matched_before[qid] = query.matcher.documentMatches(doc).result;
} else {
// Because we don't support skip or limit (yet) in unordered queries, we
// can just do a direct lookup.
@@ -676,12 +710,15 @@ LocalCollection.prototype._modifyAndNotify = function (
var old_doc = EJSON.clone(doc);
LocalCollection._modify(doc, mod);
LocalCollection._modify(doc, mod, {arrayIndex: arrayIndex});
for (qid in self.queries) {
query = self.queries[qid];
var before = matched_before[qid];
var after = query.selector_f(doc);
var afterMatch = query.matcher.documentMatches(doc);
var after = afterMatch.result;
if (after && query.distances && afterMatch.distance !== undefined)
query.distances.set(doc._id, afterMatch.distance);
if (query.cursor.skip || query.cursor.limit) {
// We need to recompute any query where the doc may have been in the
@@ -713,12 +750,13 @@ LocalCollection._insertInResults = function (query, doc) {
var fields = EJSON.clone(doc);
delete fields._id;
if (query.ordered) {
if (!query.sort_f) {
if (!query.sorter) {
query.addedBefore(doc._id, fields, null);
query.results.push(doc);
} else {
var i = LocalCollection._insertInSortedList(
query.sort_f, query.results, doc);
query.sorter.getComparator({distances: query.distances}),
query.results, doc);
var next = query.results[i+1];
if (next)
next = next._id;
@@ -761,14 +799,15 @@ LocalCollection._updateInResults = function (query, doc, old_doc) {
if (!_.isEmpty(changedFields))
query.changed(doc._id, changedFields);
if (!query.sort_f)
if (!query.sorter)
return;
// just take it out and put it back in again, and see if the index
// changes
query.results.splice(orig_idx, 1);
var new_idx = LocalCollection._insertInSortedList(
query.sort_f, query.results, doc);
query.sorter.getComparator({distances: query.distances}),
query.results, doc);
if (orig_idx !== new_idx) {
var next = query.results[new_idx+1];
if (next)
@@ -790,7 +829,9 @@ LocalCollection._updateInResults = function (query, doc, old_doc) {
LocalCollection._recomputeResults = function (query, oldResults) {
if (!oldResults)
oldResults = query.results;
query.results = query.cursor._getRawObjects(query.ordered);
if (query.distances)
query.distances.clear();
query.results = query.cursor._getRawObjects(query.ordered, query.distances);
if (!query.paused) {
LocalCollection._diffQueryChanges(
@@ -974,23 +1015,3 @@ LocalCollection._makeChangedFields = function (newDoc, oldDoc) {
});
return fields;
};
// Searches $near operator in the selector recursively
// (including all $or/$and/$nor/$not branches)
var isGeoQuery = function (selector) {
return _.any(selector, function (val, key) {
// Note: _.isObject matches objects and arrays
return key === "$near" || (_.isObject(val) && isGeoQuery(val));
});
};
// Checks if $near appears under some $or/$and/$nor/$not branch
var isGeoQuerySpecial = function (selector) {
return _.any(selector, function (val, key) {
if (_.contains(['$or', '$and', '$nor', '$not'], key))
return isGeoQuery(val);
// Note: _.isObject matches objects and arrays
return _.isObject(val) && isGeoQuerySpecial(val);
});
};

View File

@@ -1,6 +1,7 @@
Tinytest.add("minimongo - modifier affects selector", function (test) {
function testSelectorPaths (sel, paths, desc) {
test.isTrue(_.isEqual(MinimongoTest.getSelectorPaths(sel), paths), desc);
var matcher = new Minimongo.Matcher(sel);
test.equal(matcher._getPaths(), paths, desc);
}
testSelectorPaths({
@@ -49,18 +50,24 @@ Tinytest.add("minimongo - modifier affects selector", function (test) {
}
}, ['a', 'b.c'], "literal object");
// Note that a and b do NOT end up in the path list, but x and y both do.
testSelectorPaths({
$or: [
{x: {$elemMatch: {a: 5}}},
{y: {$elemMatch: {b: 7}}}
]
}, ['x', 'y'], "$or and elemMatch");
function testSelectorAffectedByModifier (sel, mod, yes, desc) {
if (yes)
test.isTrue(LocalCollection._isSelectorAffectedByModifier(sel, mod, desc));
else
test.isFalse(LocalCollection._isSelectorAffectedByModifier(sel, mod, desc));
var matcher = new Minimongo.Matcher(sel);
test.equal(matcher.affectedByModifier(mod), yes, desc);
}
function affected(sel, mod, desc) {
testSelectorAffectedByModifier(sel, mod, 1, desc);
testSelectorAffectedByModifier(sel, mod, true, desc);
}
function notAffected(sel, mod, desc) {
testSelectorAffectedByModifier(sel, mod, 0, desc);
testSelectorAffectedByModifier(sel, mod, false, desc);
}
notAffected({ foo: 0 }, { $set: { bar: 1 } }, "simplest");
@@ -89,7 +96,8 @@ Tinytest.add("minimongo - modifier affects selector", function (test) {
Tinytest.add("minimongo - selector and projection combination", function (test) {
function testSelProjectionComb (sel, proj, expected, desc) {
test.equal(LocalCollection._combineSelectorAndProjection(sel, proj), expected, desc);
var matcher = new Minimongo.Matcher(sel);
test.equal(matcher.combineIntoProjection(proj), expected, desc);
}
// Test with inclusive projection
@@ -339,11 +347,15 @@ Tinytest.add("minimongo - selector and projection combination", function (test)
var test = null; // set this global in the beginning of every test
// T - should return true
// F - should return false
var oneTest = function (sel, mod, expected, desc) {
var matcher = new Minimongo.Matcher(sel);
test.equal(matcher.canBecomeTrueByModifier(mod), expected, desc);
};
function T (sel, mod, desc) {
test.isTrue(LocalCollection._canSelectorBecomeTrueByModifier(sel, mod), desc);
oneTest(sel, mod, true, desc);
}
function F (sel, mod, desc) {
test.isFalse(LocalCollection._canSelectorBecomeTrueByModifier(sel, mod), desc);
oneTest(sel, mod, false, desc);
}
Tinytest.add("minimongo - can selector become true by modifier - literals (structured tests)", function (t) {

View File

@@ -243,33 +243,49 @@ Tinytest.add("minimongo - misc", function (test) {
});
Tinytest.add("minimongo - lookup", function (test) {
var lookupA = LocalCollection._makeLookupFunction('a');
test.equal(lookupA({}), [undefined]);
test.equal(lookupA({a: 1}), [1]);
test.equal(lookupA({a: [1]}), [[1]]);
var lookupA = MinimongoTest.makeLookupFunction('a');
test.equal(lookupA({}), [{value: undefined}]);
test.equal(lookupA({a: 1}), [{value: 1}]);
test.equal(lookupA({a: [1]}), [{value: [1]}]);
var lookupAX = LocalCollection._makeLookupFunction('a.x');
test.equal(lookupAX({a: {x: 1}}), [1]);
test.equal(lookupAX({a: {x: [1]}}), [[1]]);
test.equal(lookupAX({a: 5}), [undefined]);
var lookupAX = MinimongoTest.makeLookupFunction('a.x');
test.equal(lookupAX({a: {x: 1}}), [{value: 1}]);
test.equal(lookupAX({a: {x: [1]}}), [{value: [1]}]);
test.equal(lookupAX({a: 5}), [{value: undefined}]);
test.equal(lookupAX({a: [{x: 1}, {x: [2]}, {y: 3}]}),
[1, [2], undefined]);
[{value: 1, arrayIndex: 0},
{value: [2], arrayIndex: 1},
{value: undefined, arrayIndex: 2}]);
var lookupA0X = LocalCollection._makeLookupFunction('a.0.x');
test.equal(lookupA0X({a: [{x: 1}]}), [1]);
test.equal(lookupA0X({a: [{x: [1]}]}), [[1]]);
test.equal(lookupA0X({a: 5}), [undefined]);
test.equal(lookupA0X({a: [{x: 1}, {x: [2]}, {y: 3}]}), [1]);
var lookupA0X = MinimongoTest.makeLookupFunction('a.0.x');
test.equal(lookupA0X({a: [{x: 1}]}), [
// From interpreting '0' as "0th array element".
{value: 1, arrayIndex: 0},
// From interpreting '0' as "after branching in the array, look in the
// object {x:1} for a field named 0".
{value: undefined, arrayIndex: 0}]);
test.equal(lookupA0X({a: [{x: [1]}]}), [
{value: [1], arrayIndex: 0},
{value: undefined, arrayIndex: 0}]);
test.equal(lookupA0X({a: 5}), [{value: undefined}]);
test.equal(lookupA0X({a: [{x: 1}, {x: [2]}, {y: 3}]}), [
// From interpreting '0' as "0th array element".
{value: 1, arrayIndex: 0},
// From interpreting '0' as "after branching in the array, look in the
// object {x:1} for a field named 0".
{value: undefined, arrayIndex: 0},
{value: undefined, arrayIndex: 1},
{value: undefined, arrayIndex: 2}
]);
});
Tinytest.add("minimongo - selector_compiler", function (test) {
var matches = function (should_match, selector, doc) {
var does_match = MinimongoTest.matches(selector, doc);
if (does_match != should_match) {
var matches = function (shouldMatch, selector, doc) {
var doesMatch = new Minimongo.Matcher(selector).documentMatches(doc).result;
if (doesMatch != shouldMatch) {
// XXX super janky
test.fail({type: "minimongo-ordering",
message: "minimongo match failure: document " +
(should_match ? "should match, but doesn't" :
test.fail({message: "minimongo match failure: document " +
(shouldMatch ? "should match, but doesn't" :
"shouldn't match, but does"),
selector: JSON.stringify(selector),
document: JSON.stringify(doc)
@@ -317,6 +333,13 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
match({a: 12, b: 13}, {a: [11, 12, 13], b: [13, 14, 15]});
nomatch({a: 12, b: 13}, {a: [11, 12, 13], b: [14, 15]});
// dates
var date1 = new Date;
var date2 = new Date(date1.getTime() + 1000);
match({a: date1}, {a: date1});
nomatch({a: date1}, {a: date2});
// arrays
match({a: [1,2]}, {a: [1, 2]});
match({a: [1,2]}, {a: [[1, 2]]});
@@ -372,7 +395,7 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({a: {$lt: 10}}, {a: [11, 12]});
// (there's a full suite of ordering test elsewhere)
match({a: {$lt: "null"}}, {a: null}); // tested against mongodb
nomatch({a: {$lt: "null"}}, {a: null});
match({a: {$lt: {x: [2, 3, 4]}}}, {a: {x: [1, 3, 4]}});
match({a: {$gt: {x: [2, 3, 4]}}}, {a: {x: [3, 3, 4]}});
nomatch({a: {$gt: {x: [2, 3, 4]}}}, {a: {x: [1, 3, 4]}});
@@ -408,6 +431,17 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({a: {$all: [1, 2]}}, {a: [[1, 2]]}); // tested against mongodb
nomatch({a: {$all: [1, 2]}}, {}); // tested against mongodb, field doesn't exist
nomatch({a: {$all: [1, 2]}}, {a: {foo: 'bar'}}); // tested against mongodb, field is not an object
nomatch({a: {$all: []}}, {a: []});
nomatch({a: {$all: []}}, {a: [5]});
match({a: {$all: [/i/, /e/i]}}, {a: ["foo", "bEr", "biz"]});
nomatch({a: {$all: [/i/, /e/i]}}, {a: ["foo", "bar", "biz"]});
match({a: {$all: [{b: 3}]}}, {a: [{b: 3}]});
// Members of $all other than regexps are *equality matches*, not document
// matches.
nomatch({a: {$all: [{b: 3}]}}, {a: [{b: 3, k: 4}]});
test.throws(function () {
match({a: {$all: [{$gt: 4}]}}, {});
});
// $exists
match({a: {$exists: true}}, {a: 12});
@@ -425,11 +459,32 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({a: {$exists: false}}, {a: [1]});
match({a: {$exists: false}}, {b: [1]});
match({a: {$exists: 1}}, {a: 5});
match({a: {$exists: 0}}, {b: 5});
nomatch({'a.x':{$exists: false}}, {a: [{}, {x: 5}]});
match({'a.x':{$exists: true}}, {a: [{}, {x: 5}]});
match({'a.x':{$exists: true}}, {a: [{}, {x: 5}]});
match({'a.x':{$exists: true}}, {a: {x: []}});
match({'a.x':{$exists: true}}, {a: {x: null}});
// $mod
match({a: {$mod: [10, 1]}}, {a: 11});
nomatch({a: {$mod: [10, 1]}}, {a: 12});
match({a: {$mod: [10, 1]}}, {a: [10, 11, 12]});
nomatch({a: {$mod: [10, 1]}}, {a: [10, 12]});
_.each([
5,
[10],
[10, 1, 2],
"foo",
{bar: 1},
[]
], function (badMod) {
test.throws(function () {
match({a: {$mod: badMod}}, {a: 11});
});
});
// $ne
match({a: {$ne: 1}}, {a: 2});
@@ -448,6 +503,17 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
match({a: {$ne: {x: 1}}}, {a: {x: 2}});
match({a: {$ne: {x: 1}}}, {a: {x: 1, y: 2}});
// This query means: All 'a.b' must be non-5, and some 'a.b' must be >6.
match({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 10}]});
nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 4}]});
nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 5}]});
nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 10}, {b: 5}]});
// Should work the same if the branch is at the bottom.
match({a: {$ne: 5, $gt: 6}}, {a: [2, 10]});
nomatch({a: {$ne: 5, $gt: 6}}, {a: [2, 4]});
nomatch({a: {$ne: 5, $gt: 6}}, {a: [2, 5]});
nomatch({a: {$ne: 5, $gt: 6}}, {a: [10, 5]});
// $in
match({a: {$in: [1, 2, 3]}}, {a: 2});
nomatch({a: {$in: [1, 2, 3]}}, {a: 4});
@@ -461,6 +527,23 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
match({a: {$in: [1, 2, 3]}}, {a: [4, 2]});
nomatch({a: {$in: [1, 2, 3]}}, {a: [4]});
match({a: {$in: ['x', /foo/i]}}, {a: 'x'});
match({a: {$in: ['x', /foo/i]}}, {a: 'fOo'});
match({a: {$in: ['x', /foo/i]}}, {a: ['f', 'fOo']});
nomatch({a: {$in: ['x', /foo/i]}}, {a: ['f', 'fOx']});
match({a: {$in: [1, null]}}, {});
match({'a.b': {$in: [1, null]}}, {});
match({'a.b': {$in: [1, null]}}, {a: {}});
match({'a.b': {$in: [1, null]}}, {a: {b: null}});
nomatch({'a.b': {$in: [1, null]}}, {a: {b: 5}});
nomatch({'a.b': {$in: [1]}}, {a: {b: null}});
nomatch({'a.b': {$in: [1]}}, {a: {}});
nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}]});
match({'a.b': {$in: [1, null]}}, {a: [{b: 5}, {}]});
nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}, []]});
nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}, 5]});
// $nin
nomatch({a: {$nin: [1, 2, 3]}}, {a: 2});
match({a: {$nin: [1, 2, 3]}}, {a: 4});
@@ -476,6 +559,23 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
match({a: {$nin: [1, 2, 3]}}, {a: [4]});
match({'a.b': {$nin: [1, 2, 3]}}, {a: [{b:4}]});
nomatch({a: {$nin: ['x', /foo/i]}}, {a: 'x'});
nomatch({a: {$nin: ['x', /foo/i]}}, {a: 'fOo'});
nomatch({a: {$nin: ['x', /foo/i]}}, {a: ['f', 'fOo']});
match({a: {$nin: ['x', /foo/i]}}, {a: ['f', 'fOx']});
nomatch({a: {$nin: [1, null]}}, {});
nomatch({'a.b': {$nin: [1, null]}}, {});
nomatch({'a.b': {$nin: [1, null]}}, {a: {}});
nomatch({'a.b': {$nin: [1, null]}}, {a: {b: null}});
match({'a.b': {$nin: [1, null]}}, {a: {b: 5}});
match({'a.b': {$nin: [1]}}, {a: {b: null}});
match({'a.b': {$nin: [1]}}, {a: {}});
match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}]});
nomatch({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, {}]});
match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, []]});
match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, 5]});
// $size
match({a: {$size: 0}}, {a: []});
match({a: {$size: 1}}, {a: [2]});
@@ -524,6 +624,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({a: {$type: 11}}, {a: 'x'});
nomatch({a: {$type: 11}}, {});
// The normal rule for {$type:4} (4 means array) is that it NOT good enough to
// just have an array that's the leaf that matches the path. (An array inside
// that array is good, though.)
nomatch({a: {$type: 4}}, {a: []});
nomatch({a: {$type: 4}}, {a: [1]}); // tested against mongodb
match({a: {$type: 1}}, {a: [1]});
@@ -535,6 +638,10 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({a: {$type: 1}}, {a: ["1", []]});
match({a: {$type: 2}}, {a: ["1", []]});
match({a: {$type: 4}}, {a: ["1", []]}); // tested against mongodb
// An exception to the normal rule is that an array found via numeric index is
// examined itself, and its elements are not.
match({'a.0': {$type: 4}}, {a: [[0]]});
nomatch({'a.0': {$type: 1}}, {a: [[0]]});
// regular expressions
match({a: /a/}, {a: 'cat'});
@@ -561,12 +668,24 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({a: /xxx/}, {});
nomatch({a: {$regex: 'xxx'}}, {});
match({a: {$options: 'i'}}, {a: 12});
match({b: {$options: 'i'}}, {a: 12});
test.throws(function () {
match({a: {$options: 'i'}}, {a: 12});
});
match({a: /a/}, {a: ['dog', 'cat']});
nomatch({a: /a/}, {a: ['dog', 'puppy']});
// we don't support regexps in minimongo very well (eg, there's no EJSON
// encoding so it won't go over the wire), but run these tests anyway
match({a: /a/}, {a: /a/});
match({a: /a/}, {a: ['x', /a/]});
nomatch({a: /a/}, {a: /a/i});
nomatch({a: /a/m}, {a: /a/});
nomatch({a: /a/}, {a: /b/});
nomatch({a: /5/}, {a: 5});
nomatch({a: /t/}, {a: true});
match({a: /m/i}, {a: ['x', 'xM']});
test.throws(function () {
match({a: {$regex: /a/, $options: 'x'}}, {a: 'cat'});
});
@@ -600,8 +719,45 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({"a.b": /a/}, {a: {b: "dog"}});
match({"a.b.c": null}, {});
match({"a.b.c": null}, {a: 1});
match({"a.b": null}, {a: 1});
match({"a.b.c": null}, {a: {b: 4}});
// dotted keypaths, nulls, numeric indices, arrays
nomatch({"a.b": null}, {a: [1]});
match({"a.b": []}, {a: {b: []}});
var big = {a: [{b: 1}, 2, {}, {b: [3, 4]}]};
match({"a.b": 1}, big);
match({"a.b": [3, 4]}, big);
match({"a.b": 3}, big);
match({"a.b": 4}, big);
match({"a.b": null}, big); // matches on slot 2
match({'a.1': 8}, {a: [7, 8, 9]});
nomatch({'a.1': 7}, {a: [7, 8, 9]});
nomatch({'a.1': null}, {a: [7, 8, 9]});
match({'a.1': [8, 9]}, {a: [7, [8, 9]]});
nomatch({'a.1': 6}, {a: [[6, 7], [8, 9]]});
nomatch({'a.1': 7}, {a: [[6, 7], [8, 9]]});
nomatch({'a.1': 8}, {a: [[6, 7], [8, 9]]});
nomatch({'a.1': 9}, {a: [[6, 7], [8, 9]]});
match({"a.1": 2}, {a: [0, {1: 2}, 3]});
match({"a.1": {1: 2}}, {a: [0, {1: 2}, 3]});
match({"x.1.y": 8}, {x: [7, {y: 8}, 9]});
// comes from trying '1' as key in the plain object
match({"x.1.y": null}, {x: [7, {y: 8}, 9]});
match({"a.1.b": 9}, {a: [7, {b: 9}, {1: {b: 'foo'}}]});
match({"a.1.b": 'foo'}, {a: [7, {b: 9}, {1: {b: 'foo'}}]});
match({"a.1.b": null}, {a: [7, {b: 9}, {1: {b: 'foo'}}]});
match({"a.1.b": 2}, {a: [1, [{b: 2}], 3]});
nomatch({"a.1.b": null}, {a: [1, [{b: 2}], 3]});
// this is new behavior in mongo 2.5
nomatch({"a.0.b": null}, {a: [5]});
match({"a.1": 4}, {a: [{1: 4}, 5]});
match({"a.1": 5}, {a: [{1: 4}, 5]});
nomatch({"a.1": null}, {a: [{1: 4}, 5]});
match({"a.1.foo": 4}, {a: [{1: {foo: 4}}, {foo: 5}]});
match({"a.1.foo": 5}, {a: [{1: {foo: 4}}, {foo: 5}]});
match({"a.1.foo": null}, {a: [{1: {foo: 4}}, {foo: 5}]});
// trying to access a dotted field that is undefined at some point
// down the chain
nomatch({"a.b": 1}, {x: 2});
@@ -628,6 +784,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
test.throws(function () {
match({$or: []}, {});
});
test.throws(function () {
match({$or: [5]}, {});
});
test.throws(function () {
match({$or: []}, {a: 1});
});
@@ -709,6 +868,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
test.throws(function () {
match({$nor: []}, {});
});
test.throws(function () {
match({$nor: [5]}, {});
});
test.throws(function () {
match({$nor: []}, {a: 1});
});
@@ -787,6 +949,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
test.throws(function () {
match({$and: []}, {});
});
test.throws(function () {
match({$and: [5]}, {});
});
test.throws(function () {
match({$and: []}, {a: 1});
});
@@ -866,7 +1031,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
// $where
match({$where: "this.a === 1"}, {a: 1});
match({$where: "obj.a === 1"}, {a: 1});
nomatch({$where: "this.a !== 1"}, {a: 1});
nomatch({$where: "obj.a !== 1"}, {a: 1});
nomatch({$where: "this.a === 1", a: 2}, {a: 1});
match({$where: "this.a === 1", b: 2}, {a: 1, b: 2});
match({$where: "this.a === 1 && this.b === 2"}, {a: 1, b: 2});
@@ -909,6 +1076,19 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
{dogs: [{name: "Fido", age: 5}, {name: "Rex", age: 3}]});
nomatch({dogs: {$elemMatch: {name: /e/, age: 5}}},
{dogs: [{name: "Fido", age: 5}, {name: "Rex", age: 3}]});
match({x: {$elemMatch: {y: 9}}}, {x: [{y: 9}]});
nomatch({x: {$elemMatch: {y: 9}}}, {x: [[{y: 9}]]});
match({x: {$elemMatch: {$gt: 5, $lt: 9}}}, {x: [8]});
nomatch({x: {$elemMatch: {$gt: 5, $lt: 9}}}, {x: [[8]]});
match({'a.x': {$elemMatch: {y: 9}}},
{a: [{x: []}, {x: [{y: 9}]}]});
nomatch({a: {$elemMatch: {x: 5}}}, {a: {x: 5}});
match({a: {$elemMatch: {0: {$gt: 5, $lt: 9}}}}, {a: [[6]]});
match({a: {$elemMatch: {'0.b': {$gt: 5, $lt: 9}}}}, {a: [[{b:6}]]});
// $comment
match({a: 5, $comment: "asdf"}, {a: 5});
nomatch({a: 6, $comment: "asdf"}, {a: 5});
// XXX still needs tests:
// - non-scalar arguments to $gt, $lt, etc
@@ -1259,6 +1439,14 @@ Tinytest.add("minimongo - observe ordered with projection", function (test) {
c.insert({_id: idA2, a:2});
test.equal(operations.shift(), undefined);
var cursor = c.find({}, {fields: {a: 1, _id: 0}});
test.throws(function () {
cursor.observeChanges({added: function () {}});
});
test.throws(function () {
cursor.observe({added: function () {}});
});
// test initial inserts (and backwards sort)
handle = c.find({}, {sort: {a: -1}, fields: { a: 1 } }).observe(cbs);
test.equal(operations.shift(), ['added', {a:2}, 0, null]);
@@ -1345,8 +1533,9 @@ Tinytest.add("minimongo - ordering", function (test) {
// document ordering under a sort specification
var verify = function (sorts, docs) {
_.each(sorts, function (sort) {
assert_ordering(test, LocalCollection._compileSort(sort), docs);
_.each(_.isArray(sorts) ? sorts : [sorts], function (sort) {
var sorter = new MinimongoTest.Sorter(sort);
assert_ordering(test, sorter.getComparator(), docs);
});
};
@@ -1373,14 +1562,94 @@ Tinytest.add("minimongo - ordering", function (test) {
[{c: 1}, {a: 1, b: 2}, {a: 1, b: 3}, {a: 2, b: 0}]);
test.throws(function () {
LocalCollection._compileSort("a");
new MinimongoTest.Sorter("a");
});
test.throws(function () {
LocalCollection._compileSort(123);
new MinimongoTest.Sorter(123);
});
test.equal(LocalCollection._compileSort({})({a:1}, {a:2}), 0);
// No sort spec implies everything equal.
test.equal(new MinimongoTest.Sorter({}).getComparator()({a:1}, {a:2}), 0);
// All sorts of array edge cases!
// Increasing sort sorts by the smallest element it finds; 1 < 2.
verify({a: 1}, [
{a: [1, 10, 20]},
{a: [5, 2, 99]}
]);
// Decreasing sorts by largest it finds; 99 > 20.
verify({a: -1}, [
{a: [5, 2, 99]},
{a: [1, 10, 20]}
]);
// Can also sort by specific array indices.
verify({'a.1': 1}, [
{a: [5, 2, 99]},
{a: [1, 10, 20]}
]);
// We do NOT expand sub-arrays, so the minimum in the second doc is 5, not
// -20. (Numbers always sort before arrays.)
verify({a: 1}, [
{a: [1, [10, 15], 20]},
{a: [5, [-5, -20], 18]}
]);
// The maximum in each of these is the array, since arrays are "greater" than
// numbers. And [10, 15] is greater than [-5, -20].
verify({a: -1}, [
{a: [1, [10, 15], 20]},
{a: [5, [-5, -20], 18]}
]);
// 'a.0' here ONLY means "first element of a", not "first element of something
// found in a", so it CANNOT find the 10 or -5.
verify({'a.0': 1}, [
{a: [1, [10, 15], 20]},
{a: [5, [-5, -20], 18]}
]);
verify({'a.0': -1}, [
{a: [5, [-5, -20], 18]},
{a: [1, [10, 15], 20]}
]);
// Similarly, this is just comparing [-5,-20] to [10, 15].
verify({'a.1': 1}, [
{a: [5, [-5, -20], 18]},
{a: [1, [10, 15], 20]}
]);
verify({'a.1': -1}, [
{a: [1, [10, 15], 20]},
{a: [5, [-5, -20], 18]}
]);
// Here we are just comparing [10,15] directly to [19,3] (and NOT also
// iterating over the numbers; this is implemented by setting dontIterate in
// makeLookupFunction). So [10,15]<[19,3] even though 3 is the smallest
// number you can find there.
verify({'a.1': 1}, [
{a: [1, [10, 15], 20]},
{a: [5, [19, 3], 18]}
]);
verify({'a.1': -1}, [
{a: [5, [19, 3], 18]},
{a: [1, [10, 15], 20]}
]);
// Minimal elements are 1 and 5.
verify({a: 1}, [
{a: [1, [10, 15], 20]},
{a: [5, [19, 3], 18]}
]);
// Maximal elements are [19,3] and [10,15] (because arrays sort higher than
// numbers), even though there's a 20 floating around.
verify({a: -1}, [
{a: [5, [19, 3], 18]},
{a: [1, [10, 15], 20]}
]);
// Maximal elements are [10,15] and [3,19]. [10,15] is bigger even though 19
// is the biggest number in them, because array comparison is lexicographic.
verify({a: -1}, [
{a: [1, [10, 15], 20]},
{a: [5, [3, 19], 18]}
]);
});
Tinytest.add("minimongo - sort", function (test) {
@@ -1529,26 +1798,27 @@ Tinytest.add("minimongo - binary search", function (test) {
});
Tinytest.add("minimongo - modify", function (test) {
var modify = function (doc, mod, result) {
var copy = EJSON.clone(doc);
LocalCollection._modify(copy, mod);
if (!LocalCollection._f._equal(copy, result)) {
// XXX super janky
test.fail({type: "minimongo-modifier",
message: "modifier test failure",
input_doc: JSON.stringify(doc),
modifier: JSON.stringify(mod),
expected: JSON.stringify(result),
actual: JSON.stringify(copy)
});
} else {
test.ok();
}
var modifyWithQuery = function (doc, query, mod, expected) {
var coll = new LocalCollection;
coll.insert(doc);
// The query is relevant for 'a.$.b'.
coll.update(query, mod);
var actual = coll.findOne();
delete actual._id; // added by insert
test.equal(actual, expected, EJSON.stringify({input: doc, mod: mod}));
};
var modify = function (doc, mod, expected) {
modifyWithQuery(doc, {}, mod, expected);
};
var exceptionWithQuery = function (doc, query, mod) {
var coll = new LocalCollection;
coll.insert(doc);
test.throws(function () {
coll.update(query, mod);
});
};
var exception = function (doc, mod) {
test.throws(function () {
LocalCollection._modify(EJSON.clone(doc), mod);
});
exceptionWithQuery(doc, {}, mod);
};
// document replacement
@@ -1574,18 +1844,13 @@ Tinytest.add("minimongo - modify", function (test) {
modify({a: [null, null, null]}, {$set: {'a.3.b': 12}}, {
a: [null, null, null, {b: 12}]});
exception({a: []}, {$set: {'a.b': 12}});
test.expect_fail();
exception({a: 12}, {$set: {'a.b': 99}}); // tested on mongo
test.expect_fail();
exception({a: 'x'}, {$set: {'a.b': 99}});
test.expect_fail();
exception({a: true}, {$set: {'a.b': 99}});
test.expect_fail();
exception({a: null}, {$set: {'a.b': 99}});
modify({a: {}}, {$set: {'a.3': 12}}, {a: {'3': 12}});
modify({a: []}, {$set: {'a.3': 12}}, {a: [null, null, null, 12]});
modify({}, {$set: {'': 12}}, {'': 12}); // tested on mongo
test.expect_fail();
exception({}, {$set: {'.': 12}}); // tested on mongo
modify({}, {$set: {'. ': 12}}, {'': {' ': 12}}); // tested on mongo
modify({}, {$inc: {'... ': 12}}, {'': {'': {'': {' ': 12}}}}); // tested
@@ -1597,6 +1862,62 @@ Tinytest.add("minimongo - modify", function (test) {
modify({x: [null, null]}, {$set: {'x.2.a': 1}}, {x: [null, null, {a: 1}]});
exception({x: [null, null]}, {$set: {'x.1.a': 1}});
// a.$.b
modifyWithQuery({a: [{x: 2}, {x: 4}]}, {'a.x': 4}, {$set: {'a.$.z': 9}},
{a: [{x: 2}, {x: 4, z: 9}]});
exception({a: [{x: 2}, {x: 4}]}, {$set: {'a.$.z': 9}});
exceptionWithQuery({a: [{x: 2}, {x: 4}], b: 5}, {b: 5}, {$set: {'a.$.z': 9}});
// can't have two $
exceptionWithQuery({a: [{x: [2]}]}, {'a.x': 2}, {$set: {'a.$.x.$': 9}});
modifyWithQuery({a: [5, 6, 7]}, {a: 6}, {$set: {'a.$': 9}}, {a: [5, 9, 7]});
modifyWithQuery({a: [{b: [{c: 9}, {c: 10}]}, {b: {c: 11}}]}, {'a.b.c': 10},
{$unset: {'a.$.b': 1}}, {a: [{}, {b: {c: 11}}]});
modifyWithQuery({a: [{b: [{c: 9}, {c: 10}]}, {b: {c: 11}}]}, {'a.b.c': 11},
{$unset: {'a.$.b': 1}},
{a: [{b: [{c: 9}, {c: 10}]}, {}]});
modifyWithQuery({a: [1]}, {'a.0': 1}, {$set: {'a.$': 5}}, {a: [5]});
modifyWithQuery({a: [9]}, {a: {$mod: [2, 1]}}, {$set: {'a.$': 5}}, {a: [5]});
// Negatives don't set '$'.
exceptionWithQuery({a: [1]}, {$not: {a: 2}}, {$set: {'a.$': 5}});
exceptionWithQuery({a: [1]}, {'a.0': {$ne: 2}}, {$set: {'a.$': 5}});
// One $or clause works.
modifyWithQuery({a: [{x: 2}, {x: 4}]},
{$or: [{'a.x': 4}]}, {$set: {'a.$.z': 9}},
{a: [{x: 2}, {x: 4, z: 9}]});
// More $or clauses throw.
exceptionWithQuery({a: [{x: 2}, {x: 4}]},
{$or: [{'a.x': 4}, {'a.x': 4}]},
{$set: {'a.$.z': 9}});
// $and uses the last one.
modifyWithQuery({a: [{x: 1}, {x: 3}]},
{$and: [{'a.x': 1}, {'a.x': 3}]},
{$set: {'a.$.x': 5}},
{a: [{x: 1}, {x: 5}]});
modifyWithQuery({a: [{x: 1}, {x: 3}]},
{$and: [{'a.x': 3}, {'a.x': 1}]},
{$set: {'a.$.x': 5}},
{a: [{x: 5}, {x: 3}]});
// Same goes for the implicit AND of a document selector.
modifyWithQuery({a: [{x: 1}, {y: 3}]},
{'a.x': 1, 'a.y': 3},
{$set: {'a.$.z': 5}},
{a: [{x: 1}, {y: 3, z: 5}]});
// with $near, make sure it finds the closest one
modifyWithQuery({a: [{b: [1,1]},
{b: [ [3,3], [4,4] ]},
{b: [9,9]}]},
{'a.b': {$near: [5, 5]}},
{$set: {'a.$.b': 'k'}},
{a: [{b: [1,1]}, {b: 'k'}, {b: [9,9]}]});
modifyWithQuery({a: [{x: 1}, {y: 1}, {x: 1, y: 1}]},
{a: {$elemMatch: {x: 1, y: 1}}},
{$set: {'a.$.x': 2}},
{a: [{x: 1}, {y: 1}, {x: 2, y: 1}]});
modifyWithQuery({a: [{b: [{x: 1}, {y: 1}, {x: 1, y: 1}]}]},
{'a.b': {$elemMatch: {x: 1, y: 1}}},
{$set: {'a.$.b': 3}},
{a: [{b: 3}]});
// $inc
modify({a: 1, b: 2}, {$inc: {a: 10}}, {a: 11, b: 2});
modify({a: 1, b: 2}, {$inc: {c: 10}}, {a: 1, b: 2, c: 10});
@@ -1777,10 +2098,11 @@ Tinytest.add("minimongo - modify", function (test) {
{a: {}, q: {2: {r: 12}}});
exception({a: {b: 12}, q: []}, {$rename: {'a.b': 'q.2'}}); // tested
exception({a: {b: 12}, q: []}, {$rename: {'a.b': 'q.2.r'}}); // tested
test.expect_fail();
exception({a: {b: 12}, q: []}, {$rename: {'q.1': 'x'}}); // tested
test.expect_fail();
exception({a: {b: 12}, q: []}, {$rename: {'q.1.j': 'x'}}); // tested
// These strange MongoDB behaviors throw.
// modify({a: {b: 12}, q: []}, {$rename: {'q.1': 'x'}},
// {a: {b: 12}, x: []}); // tested
// modify({a: {b: 12}, q: []}, {$rename: {'q.1.j': 'x'}},
// {a: {b: 12}, x: []}); // tested
exception({}, {$rename: {'a': 'a'}});
exception({}, {$rename: {'a.b': 'a.b'}});
modify({a: 12, b: 13}, {$rename: {a: 'b'}}, {b: 12});
@@ -2451,5 +2773,56 @@ Tinytest.add("minimongo - $near operator tests", function (test) {
}]
});
});
// array tests
coll = new LocalCollection();
coll.insert({
_id: "x",
k: 9,
a: [
{b: [
[100, 100],
[1, 1]]},
{b: [150, 150]}]});
coll.insert({
_id: "y",
k: 9,
a: {b: [5, 5]}});
var testNear = function (near, md, expected) {
test.equal(
_.pluck(
coll.find({'a.b': {$near: near, $maxDistance: md}}).fetch(), '_id'),
expected);
};
testNear([149, 149], 4, ['x']);
testNear([149, 149], 1000, ['x', 'y']);
// It's important that we figure out that 'x' is closer than 'y' to [2,2] even
// though the first within-1000 point in 'x' (ie, [100,100]) is farther than
// 'y'.
testNear([2, 2], 1000, ['x', 'y']);
// Ensure that distance is used as a tie-breaker for sort.
test.equal(
_.pluck(coll.find({'a.b': {$near: [1, 1]}}, {sort: {k: 1}}).fetch(), '_id'),
['x', 'y']);
test.equal(
_.pluck(coll.find({'a.b': {$near: [5, 5]}}, {sort: {k: 1}}).fetch(), '_id'),
['y', 'x']);
var operations = [];
var cbs = log_callbacks(operations);
var handle = coll.find({'a.b': {$near: [7,7]}}).observe(cbs);
test.length(operations, 2);
test.equal(operations.shift(), ['added', {k:9, a:{b:[5,5]}}, 0, null]);
test.equal(operations.shift(),
['added', {k: 9, a:[{b:[[100,100],[1,1]]},{b:[150,150]}]},
1, null]);
// This needs to be inserted in the MIDDLE of the two existing ones.
coll.insert({a: {b: [3,3]}});
test.length(operations, 1);
test.equal(operations.shift(), ['added', {a: {b: [3, 3]}}, 1, 'x']);
handle.stop();
});

View File

@@ -6,63 +6,59 @@
// XXX atomicity: if one modification fails, do we roll back the whole
// change?
//
// isInsert is set when _modify is being called to compute the document to
// insert as part of an upsert operation. We use this primarily to figure out
// when to set the fields in $setOnInsert, if present.
LocalCollection._modify = function (doc, mod, isInsert) {
var is_modifier = false;
for (var k in mod) {
// IE7 doesn't support indexing into strings (eg, k[0]), so use substr.
// Too bad -- it's far slower:
// http://jsperf.com/testing-the-first-character-of-a-string
is_modifier = k.substr(0, 1) === '$';
break; // just check the first key.
}
// options:
// - isInsert is set when _modify is being called to compute the document to
// insert as part of an upsert operation. We use this primarily to figure
// out when to set the fields in $setOnInsert, if present.
LocalCollection._modify = function (doc, mod, options) {
options = options || {};
if (!isPlainObject(mod))
throw MinimongoError("Modifier must be an object");
var isModifier = isOperatorObject(mod);
var new_doc;
var newDoc;
if (!is_modifier) {
if (!isModifier) {
if (mod._id && !EJSON.equals(doc._id, mod._id))
throw MinimongoError("Cannot change the _id of a document");
// replace the whole document
for (var k in mod) {
if (k.substr(0, 1) === '$')
throw MinimongoError(
"When replacing document, field name may not start with '$'");
if (/\./.test(k))
throw MinimongoError(
"When replacing document, field name may not contain '.'");
}
new_doc = mod;
newDoc = mod;
} else {
// apply modifiers
var new_doc = EJSON.clone(doc);
// apply modifiers to the doc.
newDoc = EJSON.clone(doc);
for (var op in mod) {
var mod_func = LocalCollection._modifiers[op];
_.each(mod, function (operand, op) {
var modFunc = MODIFIERS[op];
// Treat $setOnInsert as $set if this is an insert.
if (isInsert && op === '$setOnInsert')
mod_func = LocalCollection._modifiers['$set'];
if (!mod_func)
if (options.isInsert && op === '$setOnInsert')
modFunc = MODIFIERS['$set'];
if (!modFunc)
throw MinimongoError("Invalid modifier specified " + op);
for (var keypath in mod[op]) {
_.each(operand, function (arg, keypath) {
// XXX mongo doesn't allow mod field names to end in a period,
// but I don't see why.. it allows '' as a key, as does JS
if (keypath.length && keypath[keypath.length-1] === '.')
throw MinimongoError(
"Invalid mod field name, may not end in a period");
var arg = mod[op][keypath];
var keyparts = keypath.split('.');
var no_create = !!LocalCollection._noCreateModifiers[op];
var forbid_array = (op === "$rename");
var target = LocalCollection._findModTarget(new_doc, keyparts,
no_create, forbid_array);
var noCreate = _.has(NO_CREATE_MODIFIERS, op);
var forbidArray = (op === "$rename");
var target = findModTarget(newDoc, keyparts, {
noCreate: NO_CREATE_MODIFIERS[op],
forbidArray: (op === "$rename"),
arrayIndex: options.arrayIndex
});
var field = keyparts.pop();
mod_func(target, field, arg, keypath, new_doc);
}
}
modFunc(target, field, arg, keypath, newDoc);
});
});
}
// move new document into place.
@@ -74,43 +70,71 @@ LocalCollection._modify = function (doc, mod, isInsert) {
// isInsert: if we're constructing a document to insert (via upsert)
// and we're in replacement mode, not modify mode, DON'T take the
// _id from the query. This matches mongo's behavior.
if (k !== '_id' || isInsert)
if (k !== '_id' || options.isInsert)
delete doc[k];
});
for (var k in new_doc) {
doc[k] = new_doc[k];
}
_.each(newDoc, function (v, k) {
doc[k] = v;
});
};
// for a.b.c.2.d.e, keyparts should be ['a', 'b', 'c', '2', 'd', 'e'],
// and then you would operate on the 'e' property of the returned
// object. if no_create is falsey, creates intermediate levels of
// object.
//
// if options.noCreate is falsey, creates intermediate levels of
// structure as necessary, like mkdir -p (and raises an exception if
// that would mean giving a non-numeric property to an array.) if
// no_create is true, return undefined instead. may modify the last
// element of keyparts to signal to the caller that it needs to use a
// different value to index into the returned object (for example,
// ['a', '01'] -> ['a', 1]). if forbid_array is true, return null if
// the keypath goes through an array.
LocalCollection._findModTarget = function (doc, keyparts, no_create,
forbid_array) {
// options.noCreate is true, return undefined instead.
//
// may modify the last element of keyparts to signal to the caller that it needs
// to use a different value to index into the returned object (for example,
// ['a', '01'] -> ['a', 1]).
//
// if forbidArray is true, return null if the keypath goes through an array.
//
// if options.arrayIndex is defined, use this for the (first) '$' in the path.
var findModTarget = function (doc, keyparts, options) {
options = options || {};
var usedArrayIndex = false;
for (var i = 0; i < keyparts.length; i++) {
var last = (i === keyparts.length - 1);
var keypart = keyparts[i];
var numeric = /^[0-9]+$/.test(keypart);
if (no_create && (!(typeof doc === "object") || !(keypart in doc)))
return undefined;
var indexable = isIndexable(doc);
if (!indexable) {
if (options.noCreate)
return undefined;
var e = MinimongoError(
"cannot use the part '" + keypart + "' to traverse " + doc);
e.setPropertyError = true;
throw e;
}
if (doc instanceof Array) {
if (forbid_array)
if (options.forbidArray)
return null;
if (!numeric)
if (keypart === '$') {
if (usedArrayIndex)
throw MinimongoError("Too many positional (i.e. '$') elements");
if (options.arrayIndex === undefined) {
throw MinimongoError("The positional operator did not find the " +
"match needed from the query");
}
keypart = options.arrayIndex;
usedArrayIndex = true;
} else if (isNumericKey(keypart)) {
keypart = parseInt(keypart);
} else {
if (options.noCreate)
return undefined;
throw MinimongoError(
"can't append to array using string field name ["
+ keypart + "]");
keypart = parseInt(keypart);
}
if (last)
// handle 'a.01'
keyparts[i] = keypart;
if (options.noCreate && keypart >= doc.length)
return undefined;
while (doc.length < keypart)
doc.push(null);
if (!last) {
@@ -121,9 +145,14 @@ LocalCollection._findModTarget = function (doc, keyparts, no_create,
"' of list value " + JSON.stringify(doc[keypart]));
}
} else {
// XXX check valid fieldname (no $ at start, no .)
if (!last && !(keypart in doc))
doc[keypart] = {};
if (keypart.length && keypart.substr(0, 1) === '$')
throw MinimongoError("can't set field named " + keypart);
if (!(keypart in doc)) {
if (options.noCreate)
return undefined;
if (!last)
doc[keypart] = {};
}
}
if (last)
@@ -134,7 +163,7 @@ LocalCollection._findModTarget = function (doc, keyparts, no_create,
// notreached
};
LocalCollection._noCreateModifiers = {
var NO_CREATE_MODIFIERS = {
$unset: true,
$pop: true,
$rename: true,
@@ -142,7 +171,7 @@ LocalCollection._noCreateModifiers = {
$pullAll: true
};
LocalCollection._modifiers = {
var MODIFIERS = {
$inc: function (target, field, arg) {
if (typeof arg !== "number")
throw MinimongoError("Modifier $inc allowed for numbers only");
@@ -218,7 +247,8 @@ LocalCollection._modifiers = {
// XXX this allows us to use a $sort whose value is an array, but that's
// actually an extension of the Node driver, so it won't work
// server-side. Could be confusing!
sortFunction = LocalCollection._compileSort(arg.$sort);
// XXX is it correct that we don't do geo-stuff here?
sortFunction = new Sorter(arg.$sort).getComparator();
for (var i = 0; i < toPush.length; i++) {
if (LocalCollection._f._type(toPush[i]) !== 3) {
throw MinimongoError("$push like modifiers using $sort " +
@@ -304,21 +334,21 @@ LocalCollection._modifiers = {
else if (!(x instanceof Array))
throw MinimongoError("Cannot apply $pull/pullAll modifier to non-array");
else {
var out = []
var out = [];
if (typeof arg === "object" && !(arg instanceof Array)) {
// XXX would be much nicer to compile this once, rather than
// for each document we modify.. but usually we're not
// modifying that many documents, so we'll let it slide for
// now
// XXX _compileSelector isn't up for the job, because we need
// XXX Minimongo.Matcher isn't up for the job, because we need
// to permit stuff like {$pull: {a: {$gt: 4}}}.. something
// like {$gt: 4} is not normally a complete selector.
// same issue as $elemMatch possibly?
var match = LocalCollection._compileSelector(arg);
var matcher = new Minimongo.Matcher(arg);
for (var i = 0; i < x.length; i++)
if (!match(x[i]))
out.push(x[i])
if (!matcher.documentMatches(x[i]).result)
out.push(x[i]);
} else {
for (var i = 0; i < x.length; i++)
if (!LocalCollection._f._equal(x[i], arg))
@@ -338,7 +368,7 @@ LocalCollection._modifiers = {
else if (!(x instanceof Array))
throw MinimongoError("Cannot apply $pull/pullAll modifier to non-array");
else {
var out = []
var out = [];
for (var i = 0; i < x.length; i++) {
var exclude = false;
for (var j = 0; j < arg.length; j++) {
@@ -367,7 +397,7 @@ LocalCollection._modifiers = {
delete target[field];
var keyparts = arg.split('.');
var target2 = LocalCollection._findModTarget(doc, keyparts, false, true);
var target2 = findModTarget(doc, keyparts, {forbidArray: true});
if (target2 === null)
throw MinimongoError("$rename target field invalid");
var field2 = keyparts.pop();
@@ -379,12 +409,3 @@ LocalCollection._modifiers = {
throw MinimongoError("$bit is not supported");
}
};
LocalCollection._removeDollarOperators = function (selector) {
var selectorDoc = {};
for (var k in selector)
if (k.substr(0, 1) !== '$')
selectorDoc[k] = selector[k];
return selectorDoc;
};

View File

@@ -5,6 +5,7 @@ Package.describe({
Package.on_use(function (api) {
api.export('LocalCollection');
api.export('Minimongo');
api.export('MinimongoTest', { testOnly: true });
api.use(['underscore', 'json', 'ejson', 'ordered-dict', 'deps',
'random', 'ordered-dict']);
@@ -12,7 +13,9 @@ Package.on_use(function (api) {
api.use('geojson-utils');
api.add_files([
'minimongo.js',
'helpers.js',
'selector.js',
'sort.js',
'projection.js',
'modify.js',
'diff.js',

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,12 @@
// - 'foo.bar': 42
// - $unset
// - 'abc.d': 1
LocalCollection._isSelectorAffectedByModifier = function (selector, modifier) {
Minimongo.Matcher.prototype.affectedByModifier = function (modifier) {
var self = this;
// safe check for $set/$unset being objects
modifier = _.extend({ $set: {}, $unset: {} }, modifier);
var modifiedPaths = _.keys(modifier.$set).concat(_.keys(modifier.$unset));
var meaningfulPaths = getPaths(selector);
var meaningfulPaths = self._getPaths();
return _.any(modifiedPaths, function (path) {
var mod = path.split('.');
@@ -19,17 +20,17 @@ LocalCollection._isSelectorAffectedByModifier = function (selector, modifier) {
var i = 0, j = 0;
while (i < sel.length && j < mod.length) {
if (numericKey(sel[i]) && numericKey(mod[j])) {
if (isNumericKey(sel[i]) && isNumericKey(mod[j])) {
// foo.4.bar selector affected by foo.4 modifier
// foo.3.bar selector unaffected by foo.4 modifier
if (sel[i] === mod[j])
i++, j++;
else
return false;
} else if (numericKey(sel[i])) {
} else if (isNumericKey(sel[i])) {
// foo.4.bar selector unaffected by foo.bar modifier
return false;
} else if (numericKey(mod[j])) {
} else if (isNumericKey(mod[j])) {
j++;
} else if (sel[i] === mod[j])
i++, j++;
@@ -43,42 +44,33 @@ LocalCollection._isSelectorAffectedByModifier = function (selector, modifier) {
});
};
getPathsWithoutNumericKeys = function (sel) {
return _.map(getPaths(sel), function (path) {
return _.reject(path.split('.'), numericKey).join('.');
});
};
// @param selector - Object: MongoDB selector. Currently doesn't support
// $-operators and arrays well.
// @param modifier - Object: MongoDB-styled modifier with `$set`s and `$unsets`
// only. (assumed to come from oplog)
// @returns - Boolean: if after applying the modifier, selector can start
// accepting the modified value.
LocalCollection._canSelectorBecomeTrueByModifier = function (selector, modifier)
{
if (!LocalCollection._isSelectorAffectedByModifier(selector, modifier))
// Currently doesn't support $-operators and numeric indices precisely.
Minimongo.Matcher.prototype.canBecomeTrueByModifier = function (modifier) {
var self = this;
if (!this.affectedByModifier(modifier))
return false;
modifier = _.extend({$set:{}, $unset:{}}, modifier);
if (_.any(_.keys(selector), pathHasNumericKeys) ||
_.any(_.keys(modifier.$unset), pathHasNumericKeys) ||
_.any(_.keys(modifier.$set), pathHasNumericKeys))
if (!self.isEquality())
return true;
if (!isLiteralSelector(selector))
if (_.any(self._getPaths(), pathHasNumericKeys) ||
_.any(_.keys(modifier.$unset), pathHasNumericKeys) ||
_.any(_.keys(modifier.$set), pathHasNumericKeys))
return true;
// convert a selector into an object matching the selector
// { 'a.b': { ans: 42 }, 'foo.bar': null, 'foo.baz': "something" }
// => { a: { b: { ans: 42 } }, foo: { bar: null, baz: "something" } }
var doc = pathsToTree(_.keys(selector),
function (path) { return selector[path]; },
var doc = pathsToTree(self._getPaths(),
function (path) { return self._selector[path]; },
_.identity /*conflict resolution is no resolution*/);
var selectorFn = LocalCollection._compileSelector(selector);
try {
LocalCollection._modify(doc, modifier);
} catch (e) {
@@ -97,11 +89,11 @@ LocalCollection._canSelectorBecomeTrueByModifier = function (selector, modifier)
throw e;
}
return selectorFn(doc);
return self.documentMatches(doc).result;
};
// Returns a list of key paths the given selector is looking for
var getPaths = MinimongoTest.getSelectorPaths = function (sel) {
var getPaths = function (sel) {
return _.keys(new Minimongo.Matcher(sel)._paths);
return _.chain(sel).map(function (v, k) {
// we don't know how to handle $where because it can be anything
if (k === "$where")
@@ -115,23 +107,6 @@ var getPaths = MinimongoTest.getSelectorPaths = function (sel) {
};
function pathHasNumericKeys (path) {
return _.any(path.split('.'), numericKey);
}
// string can be converted to integer
function numericKey (s) {
return /^[0-9]+$/.test(s);
}
function isLiteralSelector (selector) {
return _.all(selector, function (subSelector, keyPath) {
if (keyPath.substr(0, 1) === "$" || _.isRegExp(subSelector))
return false;
if (!_.isObject(subSelector) || _.isArray(subSelector))
return true;
return _.all(subSelector, function (value, key) {
return key.substr(0, 1) !== "$";
});
});
return _.any(path.split('.'), isNumericKey);
}

View File

@@ -1,9 +1,9 @@
// Knows how to combine a mongo selector and a fields projection to a new fields
// projection taking into account active fields from the passed selector.
// @returns Object - projection object (same as fields option of mongo cursor)
LocalCollection._combineSelectorAndProjection = function (selector, projection)
{
var selectorPaths = getPathsWithoutNumericKeys(selector);
Minimongo.Matcher.prototype.combineIntoProjection = function (projection) {
var self = this;
var selectorPaths = self._getPathsElidingNumericKeys();
// Special case for $where operator in the selector - projection should depend
// on all fields of the document. getSelectorPaths returns a list of paths
@@ -40,6 +40,13 @@ LocalCollection._combineSelectorAndProjection = function (selector, projection)
}
};
Minimongo.Matcher.prototype._getPathsElidingNumericKeys = function () {
var self = this;
return _.map(self._getPaths(), function (path) {
return _.reject(path.split('.'), isNumericKey).join('.');
});
};
// Returns a set of key paths similar to
// { 'foo.bar': 1, 'a.b.c': 1 }
var treeToPaths = function (tree, prefix) {

128
packages/minimongo/sort.js Normal file
View File

@@ -0,0 +1,128 @@
// Give a sort spec, which can be in any of these forms:
// {"key1": 1, "key2": -1}
// [["key1", "asc"], ["key2", "desc"]]
// ["key1", ["key2", "desc"]]
//
// (.. with the first form being dependent on the key enumeration
// behavior of your javascript VM, which usually does what you mean in
// this case if the key names don't look like integers ..)
//
// return a function that takes two objects, and returns -1 if the
// first object comes first in order, 1 if the second object comes
// first, or 0 if neither object comes before the other.
Sorter = function (spec) {
var self = this;
var sortSpecParts = [];
if (spec instanceof Array) {
for (var i = 0; i < spec.length; i++) {
if (typeof spec[i] === "string") {
sortSpecParts.push({
lookup: makeLookupFunction(spec[i]),
ascending: true
});
} else {
sortSpecParts.push({
lookup: makeLookupFunction(spec[i][0]),
ascending: spec[i][1] !== "desc"
});
}
}
} else if (typeof spec === "object") {
for (var key in spec) {
sortSpecParts.push({
lookup: makeLookupFunction(key),
ascending: spec[key] >= 0
});
}
} else {
throw Error("Bad sort specification: ", JSON.stringify(spec));
}
// reduceValue takes in all the possible values for the sort key along various
// branches, and returns the min or max value (according to the bool
// findMin). Each value can itself be an array, and we look at its values
// too. (ie, we do a single level of flattening on branchValues, then find the
// min/max.)
//
// XXX This is actually wrong! In fact, the whole attempt to compile sort
// functions independently of selectors is wrong. In MongoDB, if you have
// documents {_id: 'x', a: [1, 10]} and {_id: 'y', a: [5, 15]},
// then C.find({}, {sort: {a: 1}}) puts x before y (1 comes before 5).
// But C.find({a: {$gt: 3}}, {sort: {a: 1}}) puts y before x (1 does not match
// the selector, and 5 comes before 10).
var reduceValue = function (branchValues, findMin) {
// Expand any leaf arrays that we find, and ignore those arrays themselves.
branchValues = expandArraysInBranches(branchValues, true);
var reduced = undefined;
var first = true;
// Iterate over all the values found in all the branches, and if a value is
// an array itself, iterate over the values in the array separately.
_.each(branchValues, function (branchValue) {
if (first) {
reduced = branchValue.value;
first = false;
} else {
// Compare the value we found to the value we found so far, saving it
// if it's less (for an ascending sort) or more (for a descending
// sort).
var cmp = LocalCollection._f._cmp(reduced, branchValue.value);
if ((findMin && cmp > 0) || (!findMin && cmp < 0))
reduced = branchValue.value;
}
});
return reduced;
};
var comparators = _.map(sortSpecParts, function (specPart) {
return function (a, b) {
var aValue = reduceValue(specPart.lookup(a), specPart.ascending);
var bValue = reduceValue(specPart.lookup(b), specPart.ascending);
var compare = LocalCollection._f._cmp(aValue, bValue);
return specPart.ascending ? compare : -compare;
};
});
self._baseComparator = composeComparators(comparators);
};
Sorter.prototype.getComparator = function (options) {
var self = this;
// If we have no distances, just use the comparator from the source
// specification (which defaults to "everything is equal".
if (!options || !options.distances) {
return self._baseComparator;
}
var distances = options.distances;
// Return a comparator which first tries the sort specification, and if that
// says "it's equal", breaks ties using $near distances.
return composeComparators([self._baseComparator, function (a, b) {
if (!distances.has(a._id))
throw Error("Missing distance for " + a._id);
if (!distances.has(b._id))
throw Error("Missing distance for " + b._id);
return distances.get(a._id) - distances.get(b._id);
}]);
};
MinimongoTest.Sorter = Sorter;
// Given an array of comparators
// (functions (a,b)->(negative or positive or zero)), returns a single
// comparator which uses each comparator in order and returns the first
// non-zero value.
var composeComparators = function (comparatorArray) {
return function (a, b) {
for (var i = 0; i < comparatorArray.length; ++i) {
var compare = comparatorArray[i](a, b);
if (compare !== 0)
return compare;
}
return 0;
};
};

View File

@@ -278,8 +278,7 @@ Meteor.Collection._rewriteSelector = function (selector) {
ret[key] = _.map(value, function (v) {
return Meteor.Collection._rewriteSelector(v);
});
}
else {
} else {
ret[key] = value;
}
});

View File

@@ -165,7 +165,7 @@ MongoConnection = function (url, options) {
self._withDb(function (db) {
dbNameFuture.return(db.databaseName);
});
self._oplogHandle = new OplogHandle(options.oplogUrl, dbNameFuture);
self._oplogHandle = new OplogHandle(options.oplogUrl, dbNameFuture.wait());
}
};
@@ -491,7 +491,7 @@ var simulateUpsertWithInsertedId = function (collection, selector, mod,
// the behavior of modifiers is concerned, whether `_modify`
// is run on EJSON or on mongo-converted EJSON.
var selectorDoc = LocalCollection._removeDollarOperators(selector);
LocalCollection._modify(selectorDoc, mod, true);
LocalCollection._modify(selectorDoc, mod, {isInsert: true});
newDoc = selectorDoc;
} else {
newDoc = mod;
@@ -957,6 +957,14 @@ MongoConnection.prototype._observeChanges = function (
return self._observeChangesTailable(cursorDescription, ordered, callbacks);
}
// You may not filter out _id when observing changes, because the id is a core
// part of the observeChanges API.
if (cursorDescription.options.fields &&
(cursorDescription.options.fields._id === 0 ||
cursorDescription.options.fields._id === false)) {
throw Error("You may not observe a cursor with {fields: {_id: 0}}");
}
var observeKey = JSON.stringify(
_.extend({ordered: ordered}, cursorDescription));
@@ -987,21 +995,32 @@ MongoConnection.prototype._observeChanges = function (
if (firstHandle) {
var driverClass = PollingObserveDriver;
if (self._oplogHandle && !ordered && !callbacks._testOnlyPollCallback
&& OplogObserveDriver.cursorSupported(cursorDescription)) {
driverClass = OplogObserveDriver;
var matcher;
if (self._oplogHandle && !ordered && !callbacks._testOnlyPollCallback) {
try {
matcher = new Minimongo.Matcher(cursorDescription.selector);
} catch (e) {
// Ignore and avoid oplog driver. eg, maybe we're trying to compile some
// newfangled $selector that minimongo doesn't support yet.
// XXX make all compilation errors MinimongoError or something
// so that this doesn't ignore unrelated exceptions
}
if (matcher
&& OplogObserveDriver.cursorSupported(cursorDescription, matcher)) {
driverClass = OplogObserveDriver;
}
}
observeDriver = new driverClass({
cursorDescription: cursorDescription,
mongoHandle: self,
multiplexer: multiplexer,
ordered: ordered,
matcher: matcher, // ignored by polling
_testOnlyPollCallback: callbacks._testOnlyPollCallback
});
// This field is only set for the first ObserveHandle in an
// ObserveMultiplexer. It is only there for use tests.
observeHandle._observeDriver = observeDriver;
// This field is only set for use in tests.
multiplexer._observeDriver = observeDriver;
}
// Blocks until the initial adds have been sent.

View File

@@ -391,13 +391,9 @@ Tinytest.addAsync("mongo-livedata - fuzz test, " + idGeneration, function(test,
}
});
// XXX What if there are multiple observe handles on the ObserveMultiplexer?
// There shouldn't be because the collection has a name unique to this
// run.
if (Meteor.isServer) {
// For now, has to be polling (not oplog).
test.isTrue(obs._observeDriver);
test.isTrue(obs._observeDriver._suspendPolling);
// For now, has to be polling (not oplog) because it is ordered observe.
test.isTrue(obs._multiplexer._observeDriver._suspendPolling);
}
var step = 0;
@@ -432,7 +428,7 @@ Tinytest.addAsync("mongo-livedata - fuzz test, " + idGeneration, function(test,
finishObserve(function () {
if (Meteor.isServer)
obs._observeDriver._suspendPolling();
obs._multiplexer._observeDriver._suspendPolling();
// Do a batch of 1-10 operations
var batch_count = rnd(10) + 1;
@@ -465,7 +461,7 @@ Tinytest.addAsync("mongo-livedata - fuzz test, " + idGeneration, function(test,
}
}
if (Meteor.isServer)
obs._observeDriver._resumePolling();
obs._multiplexer._observeDriver._resumePolling();
});
@@ -761,18 +757,46 @@ testAsyncMulti('mongo-livedata - empty documents, ' + idGeneration, [
}
var coll = new Meteor.Collection(collectionName, collectionOptions);
var docId;
coll.insert({}, expect(function (err, id) {
test.isFalse(err);
test.isTrue(id);
docId = id;
var cursor = coll.find();
test.equal(cursor.count(), 1);
}));
}
]);
// See https://github.com/meteor/meteor/issues/594.
testAsyncMulti('mongo-livedata - document with length, ' + idGeneration, [
function (test, expect) {
var self = this;
var collectionName = Random.id();
if (Meteor.isClient) {
Meteor.call('createInsecureCollection', collectionName);
Meteor.subscribe('c-' + collectionName);
}
self.coll = new Meteor.Collection(collectionName, collectionOptions);
self.coll.insert({foo: 'x', length: 0}, expect(function (err, id) {
test.isFalse(err);
test.isTrue(id);
self.docId = id;
test.equal(self.coll.findOne(self.docId),
{_id: self.docId, foo: 'x', length: 0});
}));
},
function (test, expect) {
var self = this;
self.coll.update(self.docId, {$set: {length: 5}}, expect(function (err) {
test.isFalse(err);
test.equal(self.coll.findOne(self.docId),
{_id: self.docId, foo: 'x', length: 5});
}));
}
]);
testAsyncMulti('mongo-livedata - document with a date, ' + idGeneration, [
function (test, expect) {
var collectionName = Random.id();
@@ -1886,14 +1910,12 @@ Meteor.isServer && Tinytest.add("mongo-livedata - oplog - _disableOplog", functi
if (MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle) {
var observeWithOplog = coll.find({x: 5})
.observeChanges({added: function () {}});
test.isTrue(observeWithOplog._observeDriver);
test.isTrue(observeWithOplog._observeDriver._usesOplog);
test.isTrue(observeWithOplog._multiplexer._observeDriver._usesOplog);
observeWithOplog.stop();
}
var observeWithoutOplog = coll.find({x: 6}, {_disableOplog: true})
.observeChanges({added: function () {}});
test.isTrue(observeWithoutOplog._observeDriver);
test.isFalse(observeWithoutOplog._observeDriver._usesOplog);
test.isFalse(observeWithoutOplog._multiplexer._observeDriver._usesOplog);
observeWithoutOplog.stop();
});

View File

@@ -25,6 +25,7 @@ _.each ([{added:'added', forceOrdered: true},
function (logger) {
var barid = c.insert({thing: "stuff"});
var fooid = c.insert({noodles: "good", bacon: "bad", apples: "ok"});
var handle = c.find(fooid).observeChanges(logger);
if (added === 'added')
logger.expectResult(added, [fooid, {noodles: "good", bacon: "bad",apples: "ok"}]);
@@ -43,6 +44,12 @@ _.each ([{added:'added', forceOrdered: true},
c.insert({noodles: "good", bacon: "bad", apples: "ok"});
logger.expectNoResult();
handle.stop();
var badCursor = c.find({}, {fields: {noodles: 1, _id: false}});
test.throws(function () {
badCursor.observeChanges(logger);
});
onComplete();
});
});

View File

@@ -32,14 +32,12 @@ OplogObserveDriver = function (options) {
self._published = new LocalCollection._IdMap;
var selector = self._cursorDescription.selector;
self._selectorFn = LocalCollection._compileSelector(
self._cursorDescription.selector);
self._matcher = options.matcher;
var projection = self._cursorDescription.options.fields || {};
self._projectionFn = LocalCollection._compileProjection(projection);
// Projection function, result of combining important fields for selector and
// existing fields projection
self._sharedProjection = LocalCollection._combineSelectorAndProjection(
selector, projection);
self._sharedProjection = self._matcher.combineIntoProjection(projection);
self._sharedProjectionFn = LocalCollection._compileProjection(
self._sharedProjection);
@@ -131,7 +129,7 @@ _.extend(OplogObserveDriver.prototype, {
var self = this;
newDoc = _.clone(newDoc);
var matchesNow = newDoc && self._selectorFn(newDoc);
var matchesNow = newDoc && self._matcher.documentMatches(newDoc).result;
if (mustMatchNow && !matchesNow) {
throw Error("expected " + EJSON.stringify(newDoc) + " to match "
+ EJSON.stringify(self._cursorDescription));
@@ -238,7 +236,7 @@ _.extend(OplogObserveDriver.prototype, {
// XXX what if selector yields? for now it can't but later it could have
// $where
if (self._selectorFn(op.o))
if (self._matcher.documentMatches(op.o).result)
self._add(op.o);
} else if (op.op === 'u') {
// Is this a modifier ($set/$unset, which may require us to poll the
@@ -263,8 +261,7 @@ _.extend(OplogObserveDriver.prototype, {
LocalCollection._modify(newDoc, op.o);
self._handleDoc(id, self._sharedProjectionFn(newDoc));
} else if (!canDirectlyModifyDoc ||
LocalCollection._canSelectorBecomeTrueByModifier(
self._cursorDescription.selector, op.o)) {
self._matcher.canBecomeTrueByModifier(op.o)) {
self._needToFetch.set(id, op.ts.toString());
if (self._phase === PHASE.STEADY)
self._fetchModifiedDocuments();
@@ -462,7 +459,7 @@ _.extend(OplogObserveDriver.prototype, {
// Does our oplog tailing code support this cursor? For now, we are being very
// conservative and allowing only simple queries with simple options.
// (This is a "static method".)
OplogObserveDriver.cursorSupported = function (cursorDescription) {
OplogObserveDriver.cursorSupported = function (cursorDescription, matcher) {
// First, check the options.
var options = cursorDescription.options;
@@ -488,23 +485,13 @@ OplogObserveDriver.cursorSupported = function (cursorDescription) {
}
}
// For now, we're just dealing with equality queries: no $operators, regexps,
// or $and/$or/$where/etc clauses. We can expand the scope of what we're
// comfortable processing later. ($where will get pretty scary since it will
// allow selector processing to yield!)
return _.all(cursorDescription.selector, function (value, field) {
// No logical operators like $and.
if (field.substr(0, 1) === '$')
return false;
// We only allow scalars, not sub-documents or $operators or RegExp.
// XXX Date would be easy too, though I doubt anyone is doing equality
// lookups on dates
return typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value instanceof Meteor.Collection.ObjectID;
});
// We don't allow the following selectors:
// - $where (not confident that we provide the same JS environment
// as Mongo, and can yield!)
// - $near (has "interesting" properties in MongoDB, like the possibility
// of returning an ID multiple times, though even polling maybe
// have a bug there
return !matcher.hasWhere() && !matcher.hasGeoQuery();
};
var modifierCanBeDirectlyApplied = function (modifier) {

View File

@@ -1,6 +1,7 @@
var Future = Npm.require('fibers/future');
var OPLOG_COLLECTION = 'oplog.rs';
var REPLSET_COLLECTION = 'system.replset';
// Like Perl's quotemeta: quotes all regexp metacharacters. See
// https://github.com/substack/quotemeta/blob/master/index.js
@@ -27,10 +28,10 @@ idForOp = function (op) {
throw Error("Unknown op: " + EJSON.stringify(op));
};
OplogHandle = function (oplogUrl, dbNameFuture) {
OplogHandle = function (oplogUrl, dbName) {
var self = this;
self._oplogUrl = oplogUrl;
self._dbNameFuture = dbNameFuture;
self._dbName = dbName;
self._oplogLastEntryConnection = null;
self._oplogTailConnection = null;
@@ -41,26 +42,17 @@ OplogHandle = function (oplogUrl, dbNameFuture) {
factPackage: "mongo-livedata", factName: "oplog-watchers"
});
self._lastProcessedTS = null;
// Lazily calculate the basic selector. Don't call _baseOplogSelector() at the
// top level of the constructor, because we don't want the constructor to
// block. Note that the _.once is per-handle.
self._baseOplogSelector = _.once(function () {
return {
ns: new RegExp('^' + quotemeta(self._dbNameFuture.wait()) + '\\.'),
$or: [
{ op: {$in: ['i', 'u', 'd']} },
// If it is not db.collection.drop(), ignore it
{ op: 'c', 'o.drop': { $exists: true } }]
};
});
self._baseOplogSelector = {
ns: new RegExp('^' + quotemeta(self._dbName) + '\\.'),
$or: [
{ op: {$in: ['i', 'u', 'd']} },
// If it is not db.collection.drop(), ignore it
{ op: 'c', 'o.drop': { $exists: true } }]
};
// XXX doc
self._catchingUpFutures = [];
// Setting up the connections and tail handler is a blocking operation, so we
// do it "later".
Meteor.defer(function () {
self._startTailing();
});
self._startTailing();
};
_.extend(OplogHandle.prototype, {
@@ -117,7 +109,7 @@ _.extend(OplogHandle.prototype, {
// tailing selector (ie, we need to specify the DB name) or else we might
// find a TS that won't show up in the actual tail stream.
var lastEntry = self._oplogLastEntryConnection.findOne(
OPLOG_COLLECTION, self._baseOplogSelector(),
OPLOG_COLLECTION, self._baseOplogSelector,
{fields: {ts: 1}, sort: {$natural: -1}});
if (!lastEntry) {
@@ -168,13 +160,19 @@ _.extend(OplogHandle.prototype, {
self._oplogLastEntryConnection = new MongoConnection(
self._oplogUrl, {poolSize: 1});
// Find the last oplog entry. Blocks until the connection is ready.
// First, make sure that there actually is a repl set here. If not, oplog
// tailing won't ever find anything! (Blocks until the connection is ready.)
var replSetInfo = self._oplogLastEntryConnection.findOne(
REPLSET_COLLECTION, {});
if (!replSetInfo)
throw Error("$MONGO_OPLOG_URL must be set to the 'local' database of " +
"a Mongo replica set");
// Find the last oplog entry.
var lastOplogEntry = self._oplogLastEntryConnection.findOne(
OPLOG_COLLECTION, {}, {sort: {$natural: -1}});
var dbName = self._dbNameFuture.wait();
var oplogSelector = _.clone(self._baseOplogSelector());
var oplogSelector = _.clone(self._baseOplogSelector);
if (lastOplogEntry) {
// Start after the last entry that currently exists.
oplogSelector.ts = {$gt: lastOplogEntry.ts};
@@ -189,11 +187,13 @@ _.extend(OplogHandle.prototype, {
self._tailHandle = self._oplogTailConnection.tail(
cursorDescription, function (doc) {
if (!(doc.ns && doc.ns.length > dbName.length + 1 &&
doc.ns.substr(0, dbName.length + 1) === (dbName + '.')))
if (!(doc.ns && doc.ns.length > self._dbName.length + 1 &&
doc.ns.substr(0, self._dbName.length + 1) ===
(self._dbName + '.'))) {
throw new Error("Unexpected ns");
}
var trigger = {collection: doc.ns.substr(dbName.length + 1),
var trigger = {collection: doc.ns.substr(self._dbName.length + 1),
dropCollection: false,
op: doc};

View File

@@ -3,9 +3,9 @@ var OplogCollection = new Meteor.Collection("oplog-" + Random.id());
Tinytest.add("mongo-livedata - oplog - cursorSupported", function (test) {
var supported = function (expected, selector) {
var cursor = OplogCollection.find(selector);
test.equal(
MongoTest.OplogObserveDriver.cursorSupported(cursor._cursorDescription),
expected);
var handle = cursor.observeChanges({added: function () {}});
test.equal(!!handle._multiplexer._observeDriver._usesOplog, expected);
handle.stop();
};
supported(true, "asdf");
@@ -25,8 +25,17 @@ Tinytest.add("mongo-livedata - oplog - cursorSupported", function (test) {
supported(true, {});
supported(false, {$and: [{foo: "asdf"}, {bar: "baz"}]});
supported(false, {foo: {x: 1}});
supported(false, {foo: {$gt: 1}});
supported(false, {foo: [1, 2, 3]});
supported(true, {$and: [{foo: "asdf"}, {bar: "baz"}]});
supported(true, {foo: {x: 1}});
supported(true, {foo: {$gt: 1}});
supported(true, {foo: [1, 2, 3]});
// No $where.
supported(false, {$where: "xxx"});
supported(false, {$and: [{foo: "adsf"}, {$where: "xxx"}]});
// No geoqueries.
supported(false, {x: {$near: [1,1]}});
// Nothing Minimongo doesn't understand. (Minimongo happens to fail to
// implement $elemMatch inside $all which MongoDB supports.)
supported(false, {x: {$all: [{$elemMatch: {y: 2}}]}});
});

View File

@@ -162,7 +162,10 @@ var ensureConfigured = function(serviceName) {
Oauth._renderOauthResults = function(res, query) {
// We support ?close and ?redirect=URL. Any other query should
// just serve a blank page
if ('close' in query) { // check with 'in' because we don't set a value
if (query.error) {
Log.warn("Error in Oauth Server: " + query.error);
closePopup(res);
} else if ('close' in query) { // check with 'in' because we don't set a value
closePopup(res);
} else if (query.redirect) {
// Only redirect to URLs on the same domain as this app.

View File

@@ -6,7 +6,7 @@ Package.describe({
Package.on_use(function (api) {
api.use('routepolicy', 'server');
api.use('webapp', 'server');
api.use(['underscore', 'service-configuration'], 'server');
api.use(['underscore', 'service-configuration', 'logging'], 'server');
api.export('Oauth');
api.export('OauthTest', 'server', {testOnly: true});

1
packages/retry/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.build*

10
packages/retry/package.js Normal file
View File

@@ -0,0 +1,10 @@
Package.describe({
summary: "Retry logic with exponential backoff",
internal: true
});
Package.on_use(function (api) {
api.use(['underscore', 'random'], ['client', 'server']);
api.export('Retry');
api.add_files('retry.js', ['client', 'server']);
});

View File

@@ -1,24 +1,23 @@
// Retry logic with an exponential backoff.
//
// options:
// baseTimeout: time for initial reconnect attempt (ms).
// exponent: exponential factor to increase timeout each attempt.
// maxTimeout: maximum time between retries (ms).
// minCount: how many times to reconnect "instantly".
// minTimeout: time to wait for the first `minCount` retries (ms).
// fuzz: factor to randomize retry times by (to avoid retry storms).
Retry = function (options) {
var self = this;
_.extend(self, _.defaults(_.clone(options || {}), {
// time for initial reconnect attempt.
baseTimeout: 1000,
// exponential factor to increase timeout each attempt.
baseTimeout: 1000, // 1 second
exponent: 2.2,
// maximum time between reconnects. keep this intentionally
// high-ish to ensure a server can recover from a failure caused
// by load
// The default is high-ish to ensure a server can recover from a
// failure caused by load.
maxTimeout: 5 * 60000, // 5 minutes
// time to wait for the first 2 retries. this helps page reload
// speed during dev mode restarts, but doesn't hurt prod too
// much (due to CONNECT_TIMEOUT)
minTimeout: 10,
// how many times to try to reconnect 'instantly'
minCount: 2,
// fuzz factor to randomize reconnect times by. avoid reconnect
// storms.
fuzz: 0.5 // +- 25%
}));
self.retryTimer = null;

View File

@@ -1055,8 +1055,7 @@ Spark.list = function (cursor, itemFunc, elseFunc) {
var frag = itemDict.get(id).liveRange.extract();
if (before === null) {
itemDict.lastValue().liveRange.insertAfter(frag);
}
else {
} else {
itemDict.get(before).liveRange.insertBefore(frag);
}
itemDict.moveBefore(id, before);

View File

@@ -17,8 +17,7 @@ makeTestConnection = function (test, succeeded, failed) {
if (serverConns[serverConn.id]) {
test.fail("onConnection callback called multiple times for same session id");
failed();
}
else {
} else {
serverConns[serverConn.id] = serverConn;
}
});
@@ -30,8 +29,7 @@ makeTestConnection = function (test, succeeded, failed) {
if (! serverConn) {
test.fail("No onConnection received server side for connected client");
failed();
}
else {
} else {
onConnectionHandle.stop();
succeeded(clientConn, serverConn);
}

View File

@@ -20,5 +20,10 @@ Package.on_use(function (api) {
api.export('_');
// NOTE: we patch _.each and various other functions that polymorphically take
// objects, arrays, and array-like objects (such as the querySelectorAll
// return value, document.images, and 'arguments') such that objects with a
// numeric length field whose constructor === Object are still treated as
// objects, not as arrays. Search for looksLikeArray.
api.add_files(['pre.js', 'underscore.js', 'post.js']);
});

View File

@@ -70,6 +70,16 @@
// Collection Functions
// --------------------
// METEOR CHANGE: _.each({length: 5}) should be treated like an object, not an
// array. This looksLikeArray function is introduced by Meteor, and replaces
// all instances of `obj.length === +obj.length`.
// https://github.com/meteor/meteor/issues/594
// https://github.com/jashkenas/underscore/issues/770
var looksLikeArray = function (obj) {
return (obj.length === +obj.length
&& (_.isArguments(obj) || obj.constructor !== Object));
};
// The cornerstone, an `each` implementation, aka `forEach`.
// Handles objects with the built-in `forEach`, arrays, and raw objects.
// Delegates to **ECMAScript 5**'s native `forEach` if available.
@@ -77,7 +87,7 @@
if (obj == null) return;
if (nativeForEach && obj.forEach === nativeForEach) {
obj.forEach(iterator, context);
} else if (obj.length === +obj.length) {
} else if (looksLikeArray(obj)) {
for (var i = 0, length = obj.length; i < length; i++) {
if (iterator.call(context, obj[i], i, obj) === breaker) return;
}
@@ -134,7 +144,7 @@
return initial ? obj.reduceRight(iterator, memo) : obj.reduceRight(iterator);
}
var length = obj.length;
if (length !== +length) {
if (!looksLikeArray(obj)) {
var keys = _.keys(obj);
length = keys.length;
}
@@ -381,14 +391,14 @@
_.toArray = function(obj) {
if (!obj) return [];
if (_.isArray(obj)) return slice.call(obj);
if (obj.length === +obj.length) return _.map(obj, _.identity);
if (looksLikeArray(obj)) return _.map(obj, _.identity);
return _.values(obj);
};
// Return the number of elements in an object.
_.size = function(obj) {
if (obj == null) return 0;
return (obj.length === +obj.length) ? obj.length : _.keys(obj).length;
return (looksLikeArray(obj)) ? obj.length : _.keys(obj).length;
};
// Array Functions

View File

@@ -580,8 +580,9 @@ var runWebAppServer = function () {
// on a per-job basis. Discuss w/ teammates.
proxyBinding = AppConfig.configureService(
"proxy",
"pre0",
function (proxyService) {
if (proxyService.providers.proxy) {
if (proxyService && ! _.isEmpty(proxyService)) {
var proxyConf;
// XXX Figure out a per-job way to specify bind location
// (besides hardcoding the location for ADMIN_APP jobs).
@@ -602,9 +603,9 @@ var runWebAppServer = function () {
proxyConf = configuration.proxy;
}
Log("Attempting to bind to proxy at " +
proxyService.providers.proxy);
proxyService);
WebAppInternals.bindToProxy(_.extend({
proxyEndpoint: proxyService.providers.proxy
proxyEndpoint: proxyService
}, proxyConf));
}
}

View File

@@ -1,5 +1,4 @@
=> Meteor 0.7.0: use MongoDB's operations log to observe equality
queries instead of polling the database server on each update.
=> Meteor 0.7.0.1: Fix failure to initialize local MongoDB server.
This release is being downloaded in the background. Update your
project to Meteor 0.7.0 by running 'meteor update'.
project to Meteor 0.7.0.1 by running 'meteor update'.

View File

@@ -1,5 +1,19 @@
#!/bin/sh
# This is the Meteor install script!
# Are you looking at this in your web browser, and would like to install Meteor?
# Just open up your terminal and type:
#
# curl https://install.meteor.com/ | sh
#
# Meteor currently supports:
# - Mac: OS X 10.6 and above
# - Linux: x86 and x86_64 systems
# Now, on to the actual installer!
## NOTE sh NOT bash. This script should be POSIX sh only, since we don't
## know what shell the user has. Debian uses 'dash' for 'sh', for
## example.

View File

@@ -75,6 +75,9 @@
{
"release": "0.7.0"
},
{
"release": "0.7.0.1"
},
{
"release": "NEXT"
}

View File

@@ -196,15 +196,28 @@ var tryRevokeOldTokens = function (options) {
}
var response = result.response;
if (response.statusCode === 200) {
// Server confirms that the tokens have been revoked
// (Be careful to reread session data in case httpHelpers changed it)
data = readSessionData();
var session = getSession(data, domain);
session.pendingRevoke = _.difference(session.pendingRevoke, tokenIds);
if (! session.pendingRevoke.length)
delete session.pendingRevoke;
writeSessionData(data);
if (response.statusCode === 200 &&
response.body) {
try {
var body = JSON.parse(response.body);
if (body.tokenRevoked) {
// Server confirms that the tokens have been revoked. Checking for a
// `tokenRevoked` key in the response confirms that we hit an actual
// accounts server that understands that we were trying to revoke some
// tokens, not just a random URL that happened to return a 200
// response.
// (Be careful to reread session data in case httpHelpers changed it)
data = readSessionData();
var session = getSession(data, domain);
session.pendingRevoke = _.difference(session.pendingRevoke, tokenIds);
if (! session.pendingRevoke.length)
delete session.pendingRevoke;
writeSessionData(data);
}
} catch (e) {
logoutFailWarning(domain);
}
} else {
logoutFailWarning(domain);
}
@@ -309,12 +322,14 @@ var logInToGalaxy = function (galaxyName) {
method: 'GET',
strictSSL: true,
headers: {
cookie: 'GALAXY_OAUTH_SESSION=' + session
cookie: 'GALAXY_OAUTH_SESSION=' + session +
'; GALAXY_USER_AGENT_TOOL=' +
encodeURIComponent(JSON.stringify(utils.getAgentInfo()))
}
});
var body = JSON.parse(galaxyResult.body);
} catch (e) {
return { error: 'no-galaxy' };
return { error: (body && body.error) || 'no-galaxy' };
}
response = galaxyResult.response;
@@ -324,7 +339,7 @@ var logInToGalaxy = function (galaxyName) {
if (response.statusCode !== 200 ||
! body ||
! _.has(galaxyResult.setCookie, 'GALAXY_AUTH'))
return { error: 'access-denied' };
return { error: (body && body.error) || 'access-denied' };
return {
token: galaxyResult.setCookie.GALAXY_AUTH,
@@ -418,9 +433,20 @@ exports.loginCommand = function (options) {
var galaxyLoginResult = logInToGalaxy(galaxy);
if (galaxyLoginResult.error) {
// XXX add human readable error messages
process.stdout.write('\nLogin to ' + galaxy + ' failed: ' +
galaxyLoginResult.error + '\n');
return 1
process.stdout.write('\nLogin to ' + galaxy + ' failed. ');
if (galaxyLoginResult.error === 'unauthorized') {
process.stdout.write('You are not authorized for this galaxy.\n');
} else if (galaxyLoginResult.error === 'no_oauth_server') {
process.stdout.write('The galaxy could not ' +
'contact Meteor Accounts.\n');
} else if (galaxyLoginResult.error === 'no_identity') {
process.stdout.write('Your login information could not be found.\n');
} else {
process.stdout.write('Error: ' + galaxyLoginResult.error + '\n');
}
return 1;
}
data = readSessionData(); // be careful to reread data file after RPC
var session = getSession(data, galaxy);
@@ -437,6 +463,7 @@ exports.loginCommand = function (options) {
(currentUsername(data) ?
" as " + currentUsername(data) : "") + ".\n" +
"Thanks for being a Meteor developer!\n");
return 0;
};
exports.logoutCommand = function (options) {

View File

@@ -1474,7 +1474,8 @@ var writeSiteArchive = function (targets, outputPath, options) {
builder.write('README', { data: new Buffer(
"This is a Meteor application bundle. It has only one dependency:\n" +
"Node.js 0.10 (with the 'fibers' package). The current release of Meteor\n" +
"has been tested with Node 0.10.21. To run the application:\n" +
"has been tested with Node 0.10.22 and works best with 0.10.22 through\n" +
"0.10.24. To run the application:\n" +
"\n" +
" $ rm -r programs/server/node_modules/fibers\n" +
" $ npm install fibers@1.0.1\n" +

View File

@@ -57,7 +57,7 @@ var _assertCorrectPackageNpmDir = function (deps) {
// known to the test author. We set keys on val always in this
// order so that comparison works well.
var val = {};
_.each(['version', 'from', 'resolved', 'dependencies'], function (key) {
_.each(['version', 'dependencies'], function (key) {
if (expected[key])
val[key] = expected[key];
else if (actualMeteorNpmShrinkwrapDependencies[name] && actualMeteorNpmShrinkwrapDependencies[name][key])