Repository: rethinkdb/horizon Branch: next Commit: c1fa8bdd1999 Files: 211 Total size: 638.1 KB Directory structure: gitextract_ib87vw2t/ ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.dev ├── GETTING-STARTED.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── circle.yml ├── cli/ │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── create-cert.js │ │ ├── init.js │ │ ├── main.js │ │ ├── make-token.js │ │ ├── migrate.js │ │ ├── schema.js │ │ ├── serve.js │ │ ├── utils/ │ │ │ ├── change_to_project_dir.js │ │ │ ├── check-project-name.js │ │ │ ├── config.js │ │ │ ├── each_line_in_pipe.js │ │ │ ├── initialize_joi.js │ │ │ ├── interrupt.js │ │ │ ├── is_directory.js │ │ │ ├── nice_error.js │ │ │ ├── parse_yes_no_option.js │ │ │ ├── proc-promise.js │ │ │ ├── rethrow.js │ │ │ ├── rm_sync_recursive.js │ │ │ └── start_rdb_server.js │ │ └── version.js │ └── test/ │ ├── config.js │ ├── init.spec.js │ ├── schema.spec.js │ ├── serve.spec.js │ ├── unit/ │ │ ├── check-project-name.js │ │ └── nice_error.js │ └── version.spec.js ├── client/ │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── index.d.ts │ ├── package.json │ ├── scripts/ │ │ └── compile.js │ ├── src/ │ │ ├── ast.js │ │ ├── auth.js │ │ ├── hacks/ │ │ │ └── watch-rewrites.js │ │ ├── index-polyfill.js │ │ ├── index.js │ │ ├── model.js │ │ ├── serialization.js │ │ ├── shim.js │ │ ├── socket.js │ │ └── util/ │ │ ├── check-args.js │ │ ├── glob.js │ │ ├── ordinal.js │ │ ├── query-parse.js │ │ └── valid-index-value.js │ ├── test/ │ │ ├── above.js │ │ ├── aboveSub.js │ │ ├── aggregate.js │ │ ├── aggregateSub.js │ │ ├── api.js │ │ ├── auth.js │ │ ├── below.js │ │ ├── belowSub.js │ │ ├── chaining.js │ │ ├── collection.js │ │ ├── find.js │ │ ├── findAll.js │ │ ├── findAllSub.js │ │ ├── findSub.js │ │ ├── horizonObject.js │ │ ├── insert.js │ │ ├── limit.js │ │ ├── order.js │ │ ├── orderLimitSub.js │ │ ├── remove.js │ │ ├── removeAll.js │ │ ├── replace.js │ │ ├── store.js │ │ ├── test.html │ │ ├── test.js │ │ ├── times.js │ │ ├── unit/ │ │ │ ├── ast.js │ │ │ ├── auth.js │ │ │ └── utilsTest.js │ │ ├── update.js │ │ ├── upsert.js │ │ └── utils.js │ ├── webpack.config.js │ ├── webpack.horizon.config.js │ └── webpack.test.config.js ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── examples/ │ ├── .eslintrc │ ├── README.md │ ├── angularjs-todo-app/ │ │ ├── .gitignore │ │ ├── README.md │ │ └── dist/ │ │ ├── css/ │ │ │ └── style.css │ │ ├── index.html │ │ ├── js/ │ │ │ ├── app.js │ │ │ └── controllers/ │ │ │ └── TodoController.js │ │ └── package.json │ ├── auth-app/ │ │ ├── .gitignore │ │ ├── README.md │ │ └── dist/ │ │ ├── app.css │ │ ├── app.js │ │ └── index.html │ ├── cyclejs-chat-app/ │ │ ├── README.md │ │ └── dist/ │ │ ├── app.css │ │ ├── app.js │ │ └── index.html │ ├── express-server/ │ │ ├── main.js │ │ └── package.json │ ├── hapi-server/ │ │ ├── main.js │ │ └── package.json │ ├── koa-server/ │ │ ├── main.js │ │ └── package.json │ ├── react-chat-app/ │ │ ├── README.md │ │ └── dist/ │ │ ├── app.css │ │ ├── app.jsx │ │ ├── index.html │ │ └── package.json │ ├── react-todo-app/ │ │ ├── .gitignore │ │ ├── dist/ │ │ │ ├── index.html │ │ │ ├── js/ │ │ │ │ ├── app.jsx │ │ │ │ ├── footer.jsx │ │ │ │ ├── todoItem.jsx │ │ │ │ ├── todoModel.js │ │ │ │ └── utils.js │ │ │ └── package.json │ │ └── readme.md │ ├── riotjs-chat-app/ │ │ └── dist/ │ │ ├── app.css │ │ ├── chat.tag │ │ └── index.html │ ├── vue-chat-app/ │ │ ├── .gitignore │ │ ├── README.md │ │ └── dist/ │ │ ├── app.css │ │ ├── app.js │ │ └── index.html │ └── vue-todo-app/ │ ├── .gitignore │ ├── dist/ │ │ ├── index.html │ │ ├── js/ │ │ │ ├── app.js │ │ │ ├── routes.js │ │ │ └── store.js │ │ └── package.json │ └── readme.md ├── protocol.md ├── rfcs/ │ ├── identity_mgmt.md │ └── permissions.md ├── server/ │ ├── .babelrc │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── auth/ │ │ │ ├── auth0.js │ │ │ ├── facebook.js │ │ │ ├── github.js │ │ │ ├── google.js │ │ │ ├── slack.js │ │ │ ├── twitch.js │ │ │ ├── twitter.js │ │ │ └── utils.js │ │ ├── auth.js │ │ ├── client.js │ │ ├── endpoint/ │ │ │ ├── common.js │ │ │ ├── insert.js │ │ │ ├── query.js │ │ │ ├── remove.js │ │ │ ├── replace.js │ │ │ ├── store.js │ │ │ ├── subscribe.js │ │ │ ├── update.js │ │ │ ├── upsert.js │ │ │ └── writes.js │ │ ├── error.js │ │ ├── horizon.js │ │ ├── logger.js │ │ ├── metadata/ │ │ │ ├── collection.js │ │ │ ├── index.js │ │ │ ├── metadata.js │ │ │ └── table.js │ │ ├── permissions/ │ │ │ ├── group.js │ │ │ ├── rule.js │ │ │ ├── template.js │ │ │ └── validator.js │ │ ├── reql_connection.js │ │ ├── request.js │ │ ├── schema/ │ │ │ ├── horizon_protocol.js │ │ │ └── server_options.js │ │ ├── server.js │ │ └── utils.js │ └── test/ │ ├── http_tests.js │ ├── permissions.js │ ├── prereq_tests.js │ ├── protocol_tests.js │ ├── query_tests.js │ ├── schema.js │ ├── subscribe_tests.js │ ├── test.js │ ├── utils.js │ └── write_tests.js ├── test/ │ ├── serve.js │ └── setupDev.sh └── update_versions.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ **/node_modules docs examples server/test client/test client/lib/ client/build client/dist **/README.md docker-compose.yml **/rethinkdb_data_test **/rethinkdb_data/ **/*.log .#* **/*-key.pem **/*-cert.pem **/.DS_Store .hz/ config.toml Dockerfile* .git ================================================ FILE: .eslintrc.js ================================================ const OFF = 0; const WARN = 1; const ERROR = 2; module.exports = { extends: "eslint:recommended", rules: { "arrow-body-style": [ERROR, "as-needed"], "array-bracket-spacing": [ ERROR, "always" ], "arrow-parens": [ ERROR, "always" ], "arrow-spacing": [ ERROR ], "block-spacing": [ ERROR, "always" ], "brace-style": [ ERROR, "1tbs", { "allowSingleLine": true } ], "comma-dangle": [ ERROR, "always-multiline" ], "comma-spacing": [ ERROR ], "comma-style": [ ERROR, "last" ], "constructor-super": [ ERROR ], "curly": [ ERROR, "all" ], "dot-notation": [ ERROR ], "eqeqeq": [ ERROR, "allow-null" ], "func-style": [ ERROR, "declaration", { "allowArrowFunctions": true } ], "indent": [ ERROR, 2 ], "key-spacing": [ ERROR ], "keyword-spacing": [ ERROR ], "linebreak-style": [ ERROR, "unix" ], "new-parens": [ ERROR ], "max-len": [ ERROR, 80 ], "no-array-constructor": [ ERROR ], "no-case-declarations": [ ERROR ], "no-class-assign": [ ERROR ], "no-confusing-arrow": [ ERROR, { "allowParens": true } ], "no-console": [ OFF ], "no-const-assign": [ ERROR ], "no-constant-condition": [ ERROR ], "no-dupe-class-members": [ ERROR ], "no-eval": [ ERROR ], "no-extend-native": [ ERROR ], "no-extra-semi": [ ERROR ], "no-floating-decimal": [ ERROR ], "no-implicit-coercion": [ ERROR ], "no-implied-eval": [ ERROR ], "no-invalid-this": [ ERROR ], "no-labels": [ ERROR ], "no-lonely-if": [ ERROR ], "no-mixed-requires": [ ERROR ], "no-multi-spaces": [ ERROR ], "no-multi-str": [ ERROR ], "no-multiple-empty-lines": [ ERROR, { "max": 2, "maxEOF": 1 } ], "no-native-reassign": [ ERROR ], "no-new-func": [ ERROR ], "no-new-object": [ ERROR ], "no-new-require": [ ERROR ], "no-new-wrappers": [ ERROR ], "no-param-reassign": [ ERROR ], "no-proto": [ ERROR ], "no-return-assign": [ ERROR ], "no-self-compare": [ ERROR ], "no-sequences": [ ERROR ], "no-shadow": [ ERROR ], "no-shadow-restricted-names": [ ERROR ], "no-this-before-super": [ ERROR ], "no-throw-literal": [ ERROR ], "no-trailing-spaces": [ ERROR ], "no-unexpected-multiline": [ ERROR ], "no-unneeded-ternary": [ ERROR ], "no-unreachable": [ ERROR ], "no-use-before-define": [ ERROR, "nofunc" ], "no-var": [ ERROR ], "no-void": [ ERROR ], "no-with": [ ERROR ], "object-curly-spacing": [ ERROR, "always" ], "one-var": [ ERROR, { "uninitialized": "always", "initialized": "never" } ], "operator-assignment": [ ERROR, "always" ], "operator-linebreak": [ ERROR, "after" ], "padded-blocks": [ ERROR, "never" ], "prefer-const": [ ERROR ], "prefer-template": [ ERROR ], "quote-props": [ ERROR, "as-needed" ], "quotes": [ ERROR, "single", "avoid-escape" ], "semi": [ ERROR, "always" ], "semi-spacing": [ ERROR ], "space-before-blocks": [ ERROR, "always" ], "space-before-function-paren": [ ERROR, "never" ], "space-in-parens": [ ERROR, "never" ], "space-infix-ops": [ ERROR ], "space-unary-ops": [ ERROR ], "spaced-comment": [ ERROR, "always" ], "strict": [ ERROR, "global" ], "wrap-iife": [ ERROR, "inside" ], "yoda": [ ERROR, "never" ], }, env: { "es6": true, "node": true, "mocha": true, }, }; ================================================ FILE: .gitignore ================================================ client/dist/ client/lib/ rethinkdb_data_test rethinkdb_data/ **/*.log .#* **/*-key.pem **/*-cert.pem node_modules/ **/.DS_Store .hz/ config.toml **/.vscode ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing We're happy you want to contribute! You can help us in different ways: - [Open an issue][1] with suggestions for improvements and errors you're facing - Fork this repository and submit a pull request - Improve the documentation (coming soon, see [Resources](#resources) below for now) [1]: https://github.com/rethinkdb/horizon/issues To submit a pull request, fork the [Horizon repository][3] and then clone your fork: git clone git@github.com:/horizon.git [3]: https://github.com/rethinkdb/horizon Make your suggested changes, `git push` and then [submit a pull request][4]. Note that before we can accept your pull requests, you need to sign our [Contributor License Agreement][5]. [4]: https://github.com/rethinkdb/horizon/compare/ [5]: http://rethinkdb.com/community/cla/ ## Resources Some useful resources to get started: * [Getting started with Horizon][getting-started] * [The Horizon client library API][client-api] * [Configuring the `hz` command-line tool][cli-config] [cli-config]: /cli/README.md [client-api]: /client/README.md [getting-started]: GETTING-STARTED.md ================================================ FILE: Dockerfile ================================================ # REQUIREMENTS # * Needs a RETHINKDB_URI environment variable pushed into the container at runtime, with -e RETHINKDB_URI=HOST:PORT # * Your Horizon app needs to be mounted into /usr/app using -v /path/to/app:/usr/app FROM node:5-slim RUN yes '' | adduser --disabled-password horizon && \ mkdir -p /usr/horizon /usr/app /usr/certs RUN apt update && apt install -y git COPY . /usr/horizon/ WORKDIR /usr/horizon RUN cd test; ./setupDev.sh EXPOSE 8181 VOLUME /usr/app CMD ["su", "-s", "/bin/sh", "horizon", "-c", "hz serve --bind all --connect $RETHINKDB_URI /usr/app"] ================================================ FILE: Dockerfile.dev ================================================ # REQUIREMENTS # * Needs a RETHINKDB_URI environment variable pushed into the container at runtime, with -e RETHINKDB_URI=HOST:PORT # * Your Horizon app needs to be mounted into /usr/app using -v /path/to/app:/usr/app FROM rethinkdb/horizon ENV HZ_DEV = yes ================================================ FILE: GETTING-STARTED.md ================================================ ![](/horizon.png) # Getting Started with Horizon **Getting Started** * [Installation](#installation) * [Creating your first app](#creating-your-first-app) * [Starting Horizon Server](#starting-horizon-server) * [Configuring Horizon Server](#configuring-horizon-server) * [Adding OAuth authentication](#adding-oauth-authentication) * [Intro to the Horizon Client Library](#the-horizon-client-library) * [Storing documents](#storing-documents) * [Retrieving documents](#retrieving-documents) * [Removing documents](#removing-documents) * [Watching for changes](#watching-for-changes) * [Putting it all together](#putting-it-all-together) * [Using an already existing application with Horizon](#bringing-your-app-to-horizon) * [Do I need to move all my files into the `dist` folder?](#do-i-need-to-output-all-my-files-into-the-dist-folder) * [How do I add Horizon to X?](#how-do-i-add-horizon-to-x) **Examples** * [Example Horizon Applications](#example-applications) * [Extending Horizon Server examples](#extending-horizon-server) --- ## Installation First, install horizon from npm: ```sh $ npm install -g horizon ``` ## Creating your first app Now you can initialize a new horizon project: ```sh $ hz init example-app ``` This will create a directory with the following files: ```sh $ tree -aF example-app/ example-app/ ├── dist/ │   └── index.html ├── .hz/ │   └── config.toml └── src/ ``` The `dist` directory is where you should output your static files. Horizon doesn't have any opinions about what front-end build system you use, just that the files to serve end up in `dist`. Your source files would go into `src` but that's just a convention. Horizon doesn't touch anything in `src`. If you want, you can `npm init` or `bower init` in the `example-app` directory to set up dependencies etc. `.hz/config.toml` is a [toml](https://github.com/toml-lang/toml) configuration file where you can set all the different options for Horizon Server. [Read more about available configuration options here](/cli/README.md#hzconfigtoml-file). By default, horizon creates a basic `index.html` to serve so you can verify everything is working: ```html

``` --- ## Starting Horizon Server We now need to start Horizon Server. Running `hz serve` does three main things: 1. Starts the Horizon Server node app which serves the Horizon Client API / WebSocket endpoint. 1. Serves the `horizon.js` client library. 1. Serves everything in the `dist` folder, _if it exists in the current working directory_. *[RethinkDB](https://www.rethinkdb.com/docs/install/) needs to be installed first and accessible from the Path.* Normally, running `hz serve` requires a running instance of RethinkDB as well as pre-created tables in your RethinkDB instance. Luckily, running `hz serve --dev` has all that covered for you. Here's a comparison of what happens with and without `--dev`: | | `hz serve`| `hz serve --dev` | Command-line Flag | |----------------------------|:-----------:|:-----:|----------------------| |Starts Horizon Server | ✅ | ✅ | | |Starts RethinkDB Server | ❌ | ✅ | `--start-rethinkdb` | |Insecure Mode (no HTTPS/WSS)| ❌ | ✅ | `--insecure` | |Auto creates tables | ❌ | ✅ | `--auto-create-table`| |Auto creates indexes | ❌ | ✅ | `--auto-create-index`| So when using `hz serve --dev`, you don't have to worry about explicitly creating tables, or worry about creating indexes to ensure your Horizon queries are always fast. As well, Horizon will start an instance of RethinkDB specifically for Horizon and create a `rethinkdb_data` folder in your current directory when you start `hz serve --dev` > Using authentication _requires_ that you use TLS. To setup authentication for your app you will have to use `hz serve` without `--dev` and with `--key-file` and `--cert-file` flags as well as any other options you require. Here you can find the complete list of command line flags for `hz serve` ➡️. On your local dev machine, you will usually use `hz serve --dev` which will begin a new instance of RethinkDB for you and will automatically create tables and indexes making your development workflow easy. In a production environment, you will want to just use `hz serve` and make use of the `.hz/config.toml` file. ### Configuring Horizon Server Horizon Server is configurable via the `.hz/config.toml` file which is in the [toml](https://github.com/toml-lang/toml) config format. By default, `hz serve` will look for this file in the current working directory. Here is [an example `.hz/config.toml` file from the Horizon CLI documentation](/cli/README.md#hzconfigtoml-file) ➡️. > Be warned that there is a precedence to config file setting in the order of: > environment variables > config file > command-line flags ### Adding OAuth authentication With Horizon, we wanted to make it easy to allow your users to authenticate with the accounts they already have with the most popular services. You can find [a full list of OAuth implementations we support here](/server/src/auth). The first thing you need to do is create an application with the provider you'd like to authenticate with, usually at the developer portal portion of their website. Here are links to a the providers we currently support. * 😵📖 - [Facebook](https://developers.facebook.com/apps/) * 💻🏦 - [Github](https://github.com/settings/applications/new) * 🔟100 - [Google](https://console.developers.google.com/project) * 🎮📹 - [Twitch](https://www.twitch.tv/kraken/oauth2/clients/new) * 🐦💬 - [Twitter](https://apps.twitter.com/app/new) From each of these providers you will eventually have a `client_id` and `client_secret` (sometimes just `id` and `secret`) that you will need to put into the `.hz/config.toml` configuration file. Near the bottom of the automatically generated `.hz/config.toml` file you'll see commented out sample OAuth settings, you'll just need to uncomment them out and replace the values with your `client_id` and `client_secret`. Adding Github OAuth configuration would look like this: ```toml # [auth.facebook] # id = "000000000000000" # secret = "00000000000000000000000000000000" # # [auth.google] # id = "00000000000-00000000000000000000000000000000.apps.googleusercontent.com" # secret = "000000000000000000000000" # # [auth.twitter] # id = "0000000000000000000000000" # secret = "00000000000000000000000000000000000000000000000000" # [auth.github] id = "your_client_id" secret = "your_client_secret" ``` Once you've added the lines in your `.hz/config.toml` you're basically all set. To verify that Horizon Server picked them up, run `hz serve` then go to `https://localhost:8181/horizon/auth_methods` (or where ever you are running Horizon Server) to see a list of currently active authentication options. > At this point, ensure that you're using `--key-file` and `--cert-file` with `hz serve` as you cannot have authentication without also using TLS to serve assets via HTTPS/WSS. Also ensure that you are now using `https://` for all your URLs. You should see `github` included in the object of available auth methods, if you just see a blank object like so `{ }`, ensure that you restarted Horizon Server and that it is using the `.hz/config.toml` you edited. It should look like this: ```js { github: "/horizon/github" } ``` Now the value of the property `github` is the path to replace on the current `window.location` that will begin the authentication process. Or, just type in `https://localhost:8181/horizon/github` in your browser to test it out. As a result of a successful authentication, the browser will be redirected to the root of the dev server (`https://localhost:8181/`) with the `?horizon_token=` in the query parameters and you can now consider the user properly authenticated at this point. If an error occurs somewhere during the authentication process, the browser will be redirected back to the root of the dev server with an error message in the query parameters. A couple notes to mention: * ***Where is the user data from authenticating with OAuth?***: At the moment we just allow users to prove they have an account with the given provider. But obviously part of the power of OAuth is the convenience of sharing controlled slices of user data. For example, I may want users to allow my app to have access to their friends list, or see who they're following on Github. This is coming soon, and in the future, we will allow developers to specify the requested authentication scopes and give developer access to the returned data via the Users table. * ***Why can't I configure the final redirect url?***: Customizing the final redirect_url on the original domain will be possible in the future. * ***Why doesn't Horizon use Passport?***: Passport was definitely considered for Horizon but ultimately was too heavily tied with Express to achieve the amount of extensibility we wanted. To ensure this extensibility we decided to implement our own handling of OAuth routes for the different providers. If you're still convinced we should use Passport, feel free to [open an issue](https://github.com/rethinkdb/horizon/issues/new) and direct your comments to @Tryneus. --- ## The Horizon Client Library In the boilerplate created by `hz init`, you can see that the Horizon client library is being imported from the path `/horizon/horizon.js` served by Horizon Server. If you ```html ... ... ... ``` After this script is loaded, you can connect to your running instance of Horizon Server. ```js const horizon = Horizon(); ``` From here you can start to interact with Horizon collections. Having `--dev` mode enabled on the Horizon Server creates collections and indexes automatically so you can get your application setup with as little hassle as possible. > **Note:** With `--dev` mode enabled or `--auto-create-index`, indices will be created automatically for queries that are run that don't already match a pre-existing query. ```js // This automatically creates const chat = horizon("messages"); ``` Now, `chat` is a Horizon collection of documents. You can perform a variety of operations on this collection to filter them down to the ones you need. This most basic operations are [`.store`][store] and [`.fetch`][fetch]: ### Storing documents To store documents into the collection, we use [`.store`][store]. ```js // Object being stored let message = { text: "What a beautiful horizon 🌄!", datetime: new Date(), author: "@dalanmiller" } // Storing a document chat.store(message); ``` If we wanted, we could also add `.subscribe` at the end of [`.store`][store] and handle the document `id`s created by the server as well as any errors that occur with storing. Check out [`.store`](https://github.com/rethinkdb/horizon/tree/next/client#store-------) in the [Horizon Client docs](https://github.com/rethinkdb/horizon/tree/next/client) ➡️. ### Retrieving documents To retrieve messages from the collection we use [`.fetch`][fetch]. In this case, `.subscribe` takes a result and error handler function. ```js chat.fetch().subscribe( (items) => { items.subscribe((item) => { // Each result from the chat collection // will pass through this function console.log(item); }) }, // If an error occurs, this function // will execute with the `err` message (err) => { console.log(err); }) ``` ### Removing documents To remove documents from a collection, you can use either [`.remove`][remove] or [`.removeAll`][removeAll]: ```js // These two queries are equivalent and will remove the document with id: 1. chat.remove(1).subscribe((id) => { console.log(id) }) chat.remove({id: 1}).subscribe((id) => {console.log(id)}) ``` Or, if you have a set of documents that you'd like to remove you can pass them in as an array to [`.removeAll`][removeAll]. ```js // Will remove documents with ids 1, 2, and 3 from the collection. chat.removeAll([1, 2, 3]) ``` As with the other functions, you can chain `.subscribe` onto the remove functions and provide response and error handlers. ### Watching for changes We can also "listen" to an entire collection, query, or a single document by using [`.watch`][watch]. This is very convenient for building apps that want to update state immediately as data changes in the database. Here are a few variations of how you can use [`.watch`][watch]: ```js // Watch all documents, if any of them change, call the handler function. chat.watch().subscribe((docs) => { console.log(docs) }) // Query all documents and sort them in ascending order by datetime, // then if any of them change, the handler function is called. chat.order("datetime").watch().subscribe((docs) => { console.log(docs) }) // Find a single document in the collection, if it changes, call the handler function chat.find({author: "@dalanmiller"}).watch().subscribe((doc) => { console.log(doc) }) ``` By default, the handler you pass to `.subscribe` chained on [`.watch`][watch] will receive the entire collection of documents when one of them changes. This makes it easy when using frameworks such as [Vue](https://vuejs.org/) or [React](https://facebook.github.io/react/) allowing you to replace the current state with the new array given to you by Horizon. ```js // Our current state of chat messages let chats = []; // Query chats with `.order` which by default // is in ascending order. chat.order("datetime").watch().subscribe( // Returns the entire array (newChats) => { // Here we replace the old value of `chats` with the new // array. Frameworks such as React will re-render based // on the new values inserted into the array. Preventing you // from having to do modifications on the original array. // // In short, it's this easy! :cool: chats = newChats; }, (err) => { console.log(err); }) ``` To learn more about how Horizon works with React, check out [this complete Horizon & React example](https://github.com/rethinkdb/horizon/tree/next/examples/react-chat-app) ➡️. ## Putting it all together Now that we have the basics covered, let's pretend we are building a simple chat application where the messages are displayed in ascending order. Here are some basic functions that would allow you to build such an app. ```js let chats = []; // Retrieve all messages from the server const retrieveMessages = () => { chat.order('datetime') // fetch all results as an array .fetch() // Retrieval successful, update our model .subscribe((newChats) => { chats = chats.concat(newChats); }, // Error handler error => console.log(error), // onCompleted handler () => console.log('All results received!') ) }; // Retrieve an single item by id const retrieveMessage = id => { chat.find(id).fetch() // Retrieval successful .subscribe(result => { chats.push(result); }, // Error occurred error => console.log(error)) }; // Store new item const storeMessage = (message) => { chat.store(message) .subscribe( // Returns id of saved objects result => console.log(result), // Returns server error message error => console.log(error) // called when store is complete () => console.log('completed store') ) }; // Replace item that has equal `id` field // or insert if it doesn't exist. const updateMessage = message => { chat.replace(message); }; // Remove item from collection const deleteMessage = message => { chat.remove(message); }; ``` And lastly, the [`.watch`][watch] method basically creates a listener on the chat collection. Using just `chat.watch()`, and the new updated results will be pushed to you any time they change on the server. You can also [`.watch`][watch] changes on a query or a single document. ```js chat.watch().subscribe(chats => { // Each time through it will returns all results of your query renderChats(allChats) }, // When error occurs on server error => console.log(error) ) ``` You can also get notifications when the client connects and disconnects from the server ``` js // Triggers when client successfully connects to server horizon.onReady().subscribe(() => console.log("Connected to Horizon Server")) // Triggers when disconnected from server horizon.onDisconnected().subscribe(() => console.log("Disconnected from Horizon Server")) ``` From here, you could take any framework and add these functions to create a realtime chat application without writing a single line of backend code. There's also plenty of other functions in the Horizon Client library to meet your needs, including: [above][above], [below][below], [limit][limit], [replace][replace], and [upsert][upsert]. ## Bringing your app to Horizon We expect many people to already have an application in place but want to leverage the power of Horizon for their realtime data. Here are a few scenarios that will be relevant to you: ### Do I need to output all my files into the `dist` folder? The short and long answer is, **_no_**. If you are already using some other process to serve your static files, you absolutely do not need to now do Yet Another Refactor™️ just to get the power of Horizon. From your already existing code base you have two options to get include and then `require` the Horizon Client library: 1. Use `horizon.js` served by Horizon Server (simplest option) 1. Install `@horizon/client` as a dependency in your project We recommend using the `horizon.js` library as served by Horizon Server for solely the reason that there will be no mismatches between your client library version and your current running version of Horizon Server. This means somewhere in your application, you'll need to have: ```html ``` And then when you init the Horizon connection you need to specify the `host` property: ```js const horizon = Horizon({host: 'localhost:8181'}); ``` However, if requesting the .js library at page load time isn't desirable, or you are using [webpack](https://webpack.github.io/) and similar build setups for your front-end code, just add `npm install @horizon/client` to your project, and dependency wise, you'll be good to go. Just remember that when you make connections to Horizon Server to specify the port number (which is by default `8181`) when connecting. > **Note:** This will likely require setting CORS headers on the Horizon Server responses, which is a feature in progress, refer to [issue #239 for progress](https://github.com/rethinkdb/horizon/issues/239). ### How do I add Horizon to X? If you already have a React, Angular, or Whatever Is Cool These Days:tm: application, you should first check our [examples directory](/examples) for different ways on how we have integrated Horizon into these frameworks. --- ## Example Applications To show how Horizon fits with your framework of choice, we've put together a handful of example applications to help you get started. * [Horizon Repo Examples Directory](https://github.com/rethinkdb/horizon/tree/next/examples) * [CycleJS Chat App](https://github.com/rethinkdb/horizon/tree/next/examples/cyclejs-chat-app) * [RiotJS Chat App](https://github.com/rethinkdb/horizon/tree/next/examples/riotjs-chat-app) * [React Chat App](https://github.com/rethinkdb/horizon/tree/next/examples/react-chat-app) * [React TodoMVC App](https://github.com/rethinkdb/horizon/tree/next/examples/react-todo-app) * [Vue Chat App](https://github.com/rethinkdb/horizon/tree/next/examples/vue-chat-app) * [Vue TodoMVC App](https://github.com/rethinkdb/horizon/tree/next/examples/vue-todo-app) ## Extending Horizon Server We also have a few examples of how you can extend Horizon Server. We imagine that once your application grows beyond the needs of simply providing the Horizon Client API, you'll want to expand and build upon Horizon Server. Here are a few examples of how to extend Horizon Server with some popular Node web frameworks. * [Extending with Koa Server](https://github.com/rethinkdb/horizon/tree/next/examples/koa-server) * [Extending with Hapi Server](https://github.com/rethinkdb/horizon/tree/next/examples/hapi-server) * [Extending with Express Server](https://github.com/rethinkdb/horizon/tree/next/examples/express-server) [above]: https://github.com/rethinkdb/horizon/tree/next/client#above-limit-integer--key-value-closed-string- [below]: https://github.com/rethinkdb/horizon/tree/next/client#below-limit-integer--key-value-closed-string- [Collection]: https://github.com/rethinkdb/horizon/tree/next/client#collection [fetch]: https://github.com/rethinkdb/horizon/tree/next/client#fetch [find]: https://github.com/rethinkdb/horizon/tree/next/client#find---id-any- [findAll]: https://github.com/rethinkdb/horizon/tree/next/client#findall--id-any----id-any-- [Horizon]: https://github.com/rethinkdb/horizon/tree/next/client#horizon [limit]: https://github.com/rethinkdb/horizon/tree/next/client#limit-num-integer- [order]: https://github.com/rethinkdb/horizon/tree/next/client#order---directionascending- [remove]: https://github.com/rethinkdb/horizon/tree/next/client#remove-id-any--id-any- [removeAll]: https://github.com/rethinkdb/horizon/tree/next/client#removeall--id-any--id-any-----id-any---id-any--- [replace]: https://github.com/rethinkdb/horizon/tree/next/client#replace-- [store]: https://github.com/rethinkdb/horizon/tree/next/client#store------- [store]: https://github.com/rethinkdb/horizon/tree/next/client#store------- [upsert]: https://github.com/rethinkdb/horizon/tree/next/client#upsert------ [watch]: https://github.com/rethinkdb/horizon/tree/next/client#watch--rawchanges-false-- ================================================ FILE: ISSUE_TEMPLATE.md ================================================ If you're reporting a bug please include the server version and client version. ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016 RethinkDB, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Horizon [Official Repository](https://github.com/rethinkdb/horizon) ## What is Horizon? Horizon is an open-source developer platform for building sophisticated realtime apps. It provides a complete backend that makes it dramatically simpler to build, deploy, manage, and scale engaging JavaScript web and mobile apps. Horizon is extensible, integrates with the Node.js stack, and allows building modern, arbitrarily complex applications. Horizon is built on top of [RethinkDB](https://www.rethinkdb.com) and consists of four components: - [__Horizon server__](/server) -- a middleware server that connects to/is built on top of RethinkDB, and exposes a simple API/protocol to front-end applications. - [__Horizon client library__](/client) -- a JavaScript client library that wraps Horizon server's protocol in a convenient API for front-end developers. - [__Horizon CLI - `hz`__](/cli) -- a command-line tool aiding in scaffolding, development, and deployment - [__GraphQL support__](https://github.com/rethinkdb/horizon/issues/125) -- the server will have a GraphQL adapter so anyone can get started building React/Relay apps without writing any backend code at the beginning. This will not ship in v1, but we'll follow up with a GraphQL adapter quickly after launch. Horizon currently has all the following services available to developers: - ✅ __Subscribe__ -- a streaming API for building realtime apps directly from the browser without writing any backend code. - ✅ __Auth__ -- an authentication API that connects to common auth providers (e.g. Facebook, Google, GitHub). - ✅ __Identity__ -- an API for listing and manipulating user accounts. - ✅ __Permissions__ -- a security model that allows the developer to protect data from unauthorized access. Upcoming versions of Horizon will likely expose the following additional services: - __Session management__ -- manage browser session and session information. - __Geolocation__ -- an API that makes it very easy to build location-aware apps. - __Presence__ -- an API for detecting presence information for a given user and sharing it with others. - __Plugins__ -- a system for extending Horizon with user-defined services in a consistent, discoverable way. - __Backend__ -- an API/protocol to integrate custom backend code with Horizon server/client-libraries. ## Why Horizon? While technologies like [RethinkDB](http://www.rethinkdb.com) and [WebSocket](https://en.wikipedia.org/wiki/WebSocket) make it possible to build engaging realtime apps, empirically there is still too much friction for most developers. Building realtime apps now requires understanding and manually orchestrating multiple systems across the software stack, understanding distributed stream processing, and learning how to deploy and scale realtime systems. The learning curve is quite steep, and most of the initial work involves boilerplate code that is far removed from the primary task of building a realtime app. Horizon sets out to solve this problem. Developers can start building apps using their favorite front-end framework using Horizon's APIs without having to write any backend code. Since Horizon stores data in RethinkDB, once the app gets sufficiently complex to need custom business logic on the backend, developers can incrementally add backend code at any time in the development cycle of their app. ## Get Involved We'd love for you to help us build Horizon. If you'd like to be a contributor, check out our [Contributing guide](/CONTRIBUTING.md). Also, to stay up-to-date on all Horizon related news and the community you should definitely [join us on Slack](http://slack.rethinkdb.com) or [follow us on Twitter](https://twitter.com/horizonjs). ![](/assets/Lets-go.png) ## FAQ Check out our FAQ at [horizon.io/faq](https://horizon.io/faq/) ### How will Horizon be licensed? The Horizon server, client and cli are available under the MIT license ================================================ FILE: circle.yml ================================================ ## Customize the test machine machine: #timezone: # America/Los_Angeles # Set the timezone # Set version of node to use #node: # version: # 5.7.0 post: - source /etc/lsb-release && echo "deb http://download.rethinkdb.com/apt $DISTRIB_CODENAME main" | sudo tee /etc/apt/sources.list.d/rethinkdb.list - wget -qO- https://download.rethinkdb.com/apt/pubkey.gpg | sudo apt-key add - - sudo apt-get update -o Dir::Etc::sourcelist="/etc/apt/sources.list.d/rethinkdb.list" -o Dir::Etc::sourceparts="-" -o APT::Get::List-Cleanup="0" - sudo apt-get install rethinkdb ## Set artifacts # general: # artifacts: # - "client/npm-debug.log" # - "server/npm-debug.log" # - "cli/npm-debug.log" ## Customize dependencies dependencies: # Cache directories for speed cache_directories: - client/node_modules - server/node_modules - cli/node_modules override: # Stop default services #- sudo service redis-server stop #- sudo service postgresql stop #- sudo service mysql stop # Prepare for client tests #- npm prune --production: # pwd: client # Prepare for server tests #- npm prune --production: # pwd: server #- npm prune --production: # pwd: cli - ./setupDev.sh: pwd: test ## Customize test commands test: pre: - ./test/serve.js: background: true # - mkdir -p $CIRCLE_TEST_REPORTS/xunit # - touch $CIRCLE_TEST_REPORTS/xunit/cli-tests.xml # - touch $CIRCLE_TEST_REPORTS/xunit/client-tests.xml # - touch $CIRCLE_TEST_REPORTS/xunit/server-tests.xml override: # Run client tests - ./node_modules/.bin/mocha --timeout 100000 dist/test.js: pwd: client parallel: false # Run server tests - ./node_modules/.bin/mocha --timeout 100000 test/test.js test/schema.js: pwd: server parallel: false # Run cli tests - ./node_modules/.bin/mocha --timeout 100000 test: pwd: cli parallel: false ================================================ FILE: cli/.eslintrc.js ================================================ const OFF = 0; const WARN = 1; const ERROR = 2; module.exports = { extends: "../.eslintrc.js", rules: { "max-len": [ ERROR, 100 ], "no-invalid-this": [ OFF ] }, env: { "es6": true, "node": true, "mocha": true, }, }; ================================================ FILE: cli/.gitignore ================================================ # Coverage directory used by tools like istanbul coverage ================================================ FILE: cli/README.md ================================================ # **Horizon** is a realtime, open-source backend for JavaScript apps. Rapidly build and deploy web or mobile apps using a simple JavaScript API. Scale your apps to millions of users without any backend code. Horizon consists of three components: * Horizon server: a middleware server that connects to/is built on top of RethinkDB, and exposes a simple API/protocol to front-end applications. * Horizon client: a JavaScript client library that wraps Horizon server’s protocol in a convenient API for front-end developers. * Horizon CLI: a command line tool, hz, aiding in scaffolding, development, and deployment. Built by the [RethinkDB](https://rethinkdb.com) team and an open-source community, Horizon lets you build sophisticated apps with lightning speed. ## Installing Horizon https://horizon.io/install/ ## Getting Started https://horizon.io/docs/getting-started/ ================================================ FILE: cli/package.json ================================================ { "name": "horizon", "version": "2.0.0", "description": "An open-source developer platform for building realtime, scalable web apps.", "main": "src/main.js", "repository": { "type": "git", "url": "git+https://github.com/rethinkdb/horizon.git" }, "scripts": { "lint": "eslint src test", "test": "mocha test test/unit --timeout 10000", "coverage": "istanbul cover _mocha test" }, "author": "RethinkDB", "license": "MIT", "bin": { "hz": "src/main.js", "horizon": "src/main.js" }, "bugs": { "url": "https://github.com/rethinkdb/horizon/issues" }, "homepage": "https://github.com/rethinkdb/horizon#readme", "dependencies": { "@horizon/server": "2.0.0", "argparse": "^1.0.3", "bluebird": "^3.4.1", "chalk": "^1.1.3", "hasbin": "^1.2.1", "joi": "^8.0.5", "jsonwebtoken": "^5.5.4", "mime-types": "^2.0.4", "open": "7.0.4", "rethinkdb": "^2.1.1", "toml": "^2.3.0" }, "devDependencies": { "chai": "^3.5.0", "eslint": "^7.3.1", "istanbul": "^0.4.3", "mocha": "2.4.5", "mock-fs": "^3.10.0", "sinon": "1.17.3", "strip-ansi": "^3.0.1", "toml": "^2.3.0" }, "engines": { "node": ">=4.0.0", "npm": ">=3.0.0" }, "preferGlobal": true } ================================================ FILE: cli/src/create-cert.js ================================================ 'use strict'; const hasbin = require('hasbin'); const spawn = require('child_process').spawn; const run = (args) => { if (args.length) { throw new Error('create-cert takes no arguments'); } // TODO: user configuration? const settings = { binaryName: 'openssl', keyOutName: 'horizon-key.pem', certOutName: 'horizon-cert.pem', algo: 'rsa', bits: '2048', days: '365', }; // generate the arguments to the command const binArgs = [ 'req', '-x509', '-nodes', '-batch', '-newkey', `${settings.algo}:${settings.bits}`, '-keyout', settings.keyOutName, '-out', settings.certOutName, '-days', settings.days, ]; return new Promise((resolve, reject) => { hasbin(settings.binaryName, (hasOpenSSL) => { // show the invocation that's about to be run console.log(`> ${settings.binaryName} ${binArgs.join(' ')}`); // if we don't have openssl, bail if (!hasOpenSSL) { reject(new Error(`Missing ${settings.binaryName}. Make sure it is on the path.`)); } // otherwise start openssl const sslProc = spawn(settings.binaryName, binArgs); // pipe output appropriately sslProc.stdout.pipe(process.stdout, { end: false }); sslProc.stderr.pipe(process.stderr, { end: false }); // say nice things to the user when it's done sslProc.on('error', reject); sslProc.on('close', (code) => { if (code) { reject(new Error(`OpenSSL failed with code ${code}.`)); } else { console.log('Everything seems to be fine. ' + 'Remember to add your shiny new certificates to your Horizon config!'); resolve(); } }); }); }); }; module.exports = { run, description: 'Generate a certificate', }; ================================================ FILE: cli/src/init.js ================================================ /* global require, module */ 'use strict'; const fs = require('fs'); const crypto = require('crypto'); const process = require('process'); const argparse = require('argparse'); const checkProjectName = require('./utils/check-project-name'); const rethrow = require('./utils/rethrow'); const makeIndexHTML = (projectName) => `\

`; const makeDefaultConfig = (projectName) => `\ # This is a TOML file ############################################################################### # IP options # 'bind' controls which local interfaces will be listened on # 'port' controls which port will be listened on #------------------------------------------------------------------------------ # bind = [ "localhost" ] # port = 8181 ############################################################################### # HTTPS Options # 'secure' will disable HTTPS and use HTTP instead when set to 'false' # 'key_file' and 'cert_file' are required for serving HTTPS #------------------------------------------------------------------------------ # secure = true # key_file = "horizon-key.pem" # cert_file = "horizon-cert.pem" ############################################################################### # App Options # 'project_name' sets the name of the RethinkDB database used to store the # application state # 'serve_static' will serve files from the given directory over HTTP/HTTPS #------------------------------------------------------------------------------ project_name = "${projectName}" # serve_static = "dist" ############################################################################### # Data Options # WARNING: these should probably not be enabled on a publically accessible # service. Tables and indexes are not lightweight objects, and allowing them # to be created like this could open the service up to denial-of-service # attacks. # 'auto_create_collection' creates a collection when one is needed but does not exist # 'auto_create_index' creates an index when one is needed but does not exist #------------------------------------------------------------------------------ # auto_create_collection = false # auto_create_index = false ############################################################################### # RethinkDB Options # 'connect' and 'start_rethinkdb' are mutually exclusive # 'connect' will connect to an existing RethinkDB instance # 'start_rethinkdb' will run an internal RethinkDB instance # 'rdb_timeout' is the number of seconds to wait when connecting to RethinkDB #------------------------------------------------------------------------------ # connect = "localhost:28015" # start_rethinkdb = false # rdb_timeout = 30 ############################################################################### # Debug Options # 'debug' enables debug log statements #------------------------------------------------------------------------------ # debug = false ############################################################################### # Authentication Options # Each auth subsection will add an endpoint for authenticating through the # specified provider. # 'token_secret' is the key used to sign jwts # 'allow_anonymous' issues new accounts to users without an auth provider # 'allow_unauthenticated' allows connections that are not tied to a user id # 'auth_redirect' specifies where users will be redirected to after login # 'access_control_allow_origin' sets a host that can access auth settings # (typically your frontend host) #------------------------------------------------------------------------------ # allow_anonymous = false # allow_unauthenticated = false # auth_redirect = "/" # access_control_allow_origin = "" # `; const makeDefaultSchema = () => `\ [groups.admin] [groups.admin.rules.carte_blanche] template = "any()" `; const makeDefaultSecrets = () => `\ token_secret = "${crypto.randomBytes(64).toString('base64')}" ############################################################################### # RethinkDB Options # 'rdb_user' is the user account to log in with when connecting to RethinkDB # 'rdb_password' is the password for the user account specified by 'rdb_user' #------------------------------------------------------------------------------ # rdb_user = 'admin' # rdb_password = '' # [auth.auth0] # host = "0000.00.auth0.com" # id = "0000000000000000000000000" # secret = "00000000000000000000000000000000000000000000000000" # # [auth.facebook] # id = "000000000000000" # secret = "00000000000000000000000000000000" # # [auth.google] # id = "00000000000-00000000000000000000000000000000.apps.googleusercontent.com" # secret = "000000000000000000000000" # # [auth.twitter] # id = "0000000000000000000000000" # secret = "00000000000000000000000000000000000000000000000000" # # [auth.github] # id = "00000000000000000000" # secret = "0000000000000000000000000000000000000000" # # [auth.twitch] # id = "0000000000000000000000000000000" # secret = "0000000000000000000000000000000" # # [auth.slack] # id = "0000000000000000000000000000000" # secret = "0000000000000000000000000000000" `; const gitignore = () => `\ rethinkdb_data **/*.log .hz/secrets.toml node_modules `; const parseArguments = (args) => { const parser = new argparse.ArgumentParser({ prog: 'hz init' }); parser.addArgument([ 'projectName' ], { action: 'store', help: 'Name of directory to create. Defaults to current directory', nargs: '?', } ); return parser.parseArgs(args); }; const fileExists = (pathName) => { try { fs.statSync(pathName); return true; } catch (e) { return false; } }; const maybeMakeDir = (createDir, dirName) => { if (createDir) { try { fs.mkdirSync(dirName); console.info(`Created new project directory ${dirName}`); } catch (e) { throw rethrow(e, `Couldn't make directory ${dirName}: ${e.message}`); } } else { console.info(`Initializing in existing directory ${dirName}`); } }; const maybeChdir = (chdirTo) => { if (chdirTo) { try { process.chdir(chdirTo); } catch (e) { if (e.code === 'ENOTDIR') { throw rethrow(e, `${chdirTo} is not a directory`); } else { throw rethrow(e, `Couldn't chdir to ${chdirTo}: ${e.message}`); } } } }; const populateDir = (projectName, dirWasPopulated, chdirTo, dirName) => { const niceDir = chdirTo ? `${dirName}/` : ''; if (!dirWasPopulated && !fileExists('src')) { fs.mkdirSync('src'); console.info(`Created ${niceDir}src directory`); } if (!dirWasPopulated && !fileExists('dist')) { fs.mkdirSync('dist'); console.info(`Created ${niceDir}dist directory`); fs.appendFileSync('./dist/index.html', makeIndexHTML(projectName)); console.info(`Created ${niceDir}dist/index.html example`); } if (!fileExists('.hz')) { fs.mkdirSync('.hz'); console.info(`Created ${niceDir}.hz directory`); } // Default permissions const permissionGeneral = { encoding: 'utf8', mode: 0o666, }; const permissionSecret = { encoding: 'utf8', mode: 0o600, // Secrets are put in this config, so set it user, read/write only }; // Create .gitignore if it doesn't exist if (!fileExists('.gitignore')) { fs.appendFileSync( '.gitignore', gitignore(), permissionGeneral ); console.info(`Created ${niceDir}.gitignore`); } else { console.info('.gitignore already exists, not touching it.'); } // Create .hz/config.toml if it doesn't exist if (!fileExists('.hz/config.toml')) { fs.appendFileSync( '.hz/config.toml', makeDefaultConfig(projectName), permissionGeneral ); console.info(`Created ${niceDir}.hz/config.toml`); } else { console.info('.hz/config.toml already exists, not touching it.'); } // Create .hz/schema.toml if it doesn't exist if (!fileExists('.hz/schema.toml')) { fs.appendFileSync( '.hz/schema.toml', makeDefaultSchema(), permissionGeneral ); console.info(`Created ${niceDir}.hz/schema.toml`); } else { console.info('.hz/schema.toml already exists, not touching it.'); } // Create .hz/secrets.toml if it doesn't exist if (!fileExists('.hz/secrets.toml')) { fs.appendFileSync( '.hz/secrets.toml', makeDefaultSecrets(), permissionSecret ); console.info(`Created ${niceDir}.hz/secrets.toml`); } else { console.info('.hz/secrets.toml already exists, not touching it.'); } }; const run = (args) => Promise.resolve(args) .then(parseArguments) .then((parsed) => { const check = checkProjectName( parsed.projectName, process.cwd(), fs.readdirSync('.') ); const projectName = check.projectName; const dirName = check.dirName; const chdirTo = check.chdirTo; const createDir = check.createDir; maybeMakeDir(createDir, dirName); maybeChdir(chdirTo); // Before we create things, check if the directory is empty const dirWasPopulated = fs.readdirSync(process.cwd()).length !== 0; populateDir(projectName, dirWasPopulated, chdirTo, dirName); }); module.exports = { run, description: 'Initialize a horizon app directory', }; ================================================ FILE: cli/src/main.js ================================================ #!/usr/bin/env node 'use strict'; // To support `pidof horizon`, by default it shows in `pidof node` process.title = 'horizon'; const chalk = require('chalk'); const path = require('path'); const initCommand = require('./init'); const serveCommand = require('./serve'); const versionCommand = require('./version'); const createCertCommand = require('./create-cert'); const schemaCommand = require('./schema'); const makeTokenCommand = require('./make-token'); const migrateCommand = require('./migrate'); const NiceError = require('./utils/nice_error'); // Mapping from command line strings to modules. To add a new command, // add an entry in this object, and create a module with the following // exported: // - run: main function for the command // - description: a string to display in the hz help text const commands = { init: initCommand, serve: serveCommand, version: versionCommand, 'create-cert': createCertCommand, 'make-token': makeTokenCommand, schema: schemaCommand, migrate: migrateCommand, }; const programName = path.basename(process.argv[1]); const help = () => { console.log(`Usage: ${programName} subcommand [args...]`); console.log('Available subcommands:'); Object.keys(commands).forEach((cmdName) => console.log(` ${cmdName} - ${commands[cmdName].description}`) ); }; const allArgs = process.argv.slice(2); if (allArgs.length === 0) { help(); process.exit(1); } const cmdName = allArgs[0]; const cmdArgs = allArgs.slice(1); if (cmdName === '-h' || cmdName === '--help' || cmdName === 'help') { help(); process.exit(0); } const command = commands[cmdName]; if (!command) { console.error(chalk.red.bold( `No such subcommand ${cmdName}, run with -h for help`)); process.exit(1); } const done = (err) => { if (err) { const errMsg = (err instanceof NiceError) ? err.niceString({ contextSize: 2 }) : err.message; console.error(chalk.red.bold(errMsg)); process.exit(1); } else { process.exit(0); } }; try { command.run(cmdArgs).then(() => done()).catch(done); } catch (err) { done(err); } ================================================ FILE: cli/src/make-token.js ================================================ 'use strict'; const interrupt = require('./utils/interrupt'); const config = require('./utils/config'); const horizon_server = require('@horizon/server'); const path = require('path'); const jwt = require('jsonwebtoken'); const r = horizon_server.r; const logger = horizon_server.logger; const argparse = require('argparse'); const parseArguments = (args) => { const parser = new argparse.ArgumentParser({ prog: 'hz make-token' }); parser.addArgument( [ '--token-secret' ], { type: 'string', metavar: 'SECRET', help: 'Secret key for signing the token.' }); parser.addArgument( [ 'user' ], { type: 'string', metavar: 'USER_ID', help: 'The ID of the user to issue a token for.' }); return parser.parseArgs(args); }; const processConfig = (parsed) => { let options; options = config.default_options(); options = config.merge_options( options, config.read_from_config_file(parsed.project_path)); options = config.merge_options( options, config.read_from_secrets_file(parsed.project_path)); options = config.merge_options(options, config.read_from_env()); options = config.merge_options(options, config.read_from_flags(parsed)); if (options.project_name === null) { options.project_name = path.basename(path.resolve(options.project_path)); } return Object.assign(options, { user: parsed.user }); }; const run = (args) => Promise.resolve().then(() => { const options = processConfig(parseArguments(args)); if (options.token_secret === null) { throw new Error('No token secret specified, unable to sign the token.'); } const token = jwt.sign( { id: options.user, provider: null }, new Buffer(options.token_secret, 'base64'), { expiresIn: '1d', algorithm: 'HS512' } ); console.log(`${token}`); }); module.exports = { run, description: 'Generate a token to log in as a user', }; ================================================ FILE: cli/src/migrate.js ================================================ 'use strict'; const chalk = require('chalk'); const r = require('rethinkdb'); const Promise = require('bluebird'); const argparse = require('argparse'); const runSaveCommand = require('./schema').runSaveCommand; const fs = require('fs'); const accessAsync = Promise.promisify(fs.access); const config = require('./utils/config'); const procPromise = require('./utils/proc-promise'); const interrupt = require('./utils/interrupt'); const change_to_project_dir = require('./utils/change_to_project_dir'); const parse_yes_no_option = require('./utils/parse_yes_no_option'); const start_rdb_server = require('./utils/start_rdb_server'); const NiceError = require('./utils/nice_error.js'); const VERSION_2_0 = [ 2, 0, 0 ]; function run(cmdArgs) { const options = processConfig(cmdArgs); interrupt.on_interrupt(() => teardown()); return Promise.resolve().bind({ options }) .then(setup) .then(validateMigration) .then(makeBackup) .then(renameUserTables) .then(moveInternalTables) .then(renameIndices) .then(rewriteHzCollectionDocs) .then(exportNewSchema) .finally(teardown); } function green() { const args = Array.from(arguments); args[0] = chalk.green(args[0]); console.log.apply(console, args); } function white() { const args = Array.from(arguments); args[0] = chalk.white(args[0]); console.log.apply(console, args); } function processConfig(cmdArgs) { // do boilerplate to get config args :/ const parser = new argparse.ArgumentParser({ prog: 'hz migrate' }); parser.addArgument([ 'project_path' ], { default: '.', nargs: '?', help: 'Change to this directory before migrating', }); parser.addArgument([ '--project-name', '-n' ], { help: 'Name of the Horizon project server', }); parser.addArgument([ '--connect', '-c' ], { metavar: 'host:port', default: undefined, help: 'Host and port of the RethinkDB server to connect to.', }); parser.addArgument([ '--rdb-user' ], { default: 'admin', metavar: 'USER', help: 'RethinkDB User', }); parser.addArgument([ '--rdb-password' ], { default: undefined, metavar: 'PASSWORD', help: 'RethinkDB Password', }); parser.addArgument([ '--start-rethinkdb' ], { metavar: 'yes|no', default: 'yes', constant: 'yes', nargs: '?', help: 'Start up a RethinkDB server in the current directory', }); parser.addArgument([ '--skip-backup' ], { metavar: 'yes|no', default: 'no', constant: 'yes', nargs: '?', help: 'Whether to perform a backup of rethinkdb_data' + ' before migrating', }); parser.addArgument([ '--nonportable-backup' ], { metavar: 'yes|no', default: 'no', constant: 'yes', nargs: '?', help: 'Allows creating a backup that is not portable, ' + "but doesn't require the RethinkDB Python driver to be " + 'installed.', }); const parsed = parser.parseArgs(cmdArgs); const confOptions = config.read_from_config_file(parsed.project_path); const envOptions = config.read_from_env(); config.merge_options(confOptions, envOptions); // Pull out the relevant settings from the config file const options = { project_path: parsed.project_path || '.', project_name: parsed.project_name || confOptions.project_name, rdb_host: parsed.rdb_host || confOptions.rdb_host || 'localhost', rdb_port: parsed.rdb_port || confOptions.rdb_port || 28015, rdb_user: parsed.rdb_user || confOptions.rdb_user || 'admin', rdb_password: parsed.rdb_password || confOptions.rdb_password || '', start_rethinkdb: parse_yes_no_option(parsed.start_rethinkdb), skip_backup: parse_yes_no_option(parsed.skip_backup), nonportable_backup: parse_yes_no_option(parsed.nonportable_backup), }; // sets rdb_host and rdb_port from connect if necessary if (parsed.connect) { config.parse_connect(parsed.connect, options); } if (options.project_name == null) { throw new NiceError('No project_name given', { description: `\ The project_name is needed to migrate from the v1.x format the v.2.0 format. \ It wasn't passed on the command line or found in your config.`, suggestions: [ 'pass the --project-name option to hz migrate', 'add the "project_name" key to your .hz/config.toml', ] }); } return options; } function setup() { // Start rethinkdb server if necessary // Connect to whatever rethinkdb server we're using white('Setup'); return Promise.resolve().then(() => { if (this.options.project_path && this.options.project_path !== '.') { green(` ├── Changing to directory ${this.options.project_path}`); change_to_project_dir(this.options.project_path); } }).then(() => { // start rethinkdb server if necessary if (this.options.start_rethinkdb) { green(' ├── Starting RethinkDB server'); return start_rdb_server({ quiet: true }).then((server) => { this.rdb_server = server; this.options.rdb_host = 'localhost'; this.options.rdb_port = server.driver_port; }); } }).then(() => { green(' ├── Connecting to RethinkDB'); return r.connect({ host: this.options.rdb_host, port: this.options.rdb_port, user: this.options.rdb_user, password: this.options.rdb_password, }); }).then((conn) => { green(' └── Successfully connected'); this.conn = conn; }); } function teardown() { return Promise.resolve().then(() => { white('Cleaning up...'); // close the rethinkdb connection if (this.conn) { green(' ├── Closing rethinkdb connection'); return this.conn.close(); } }).then(() => { // shut down the rethinkdb server if we started it if (this.rdb_server) { green(' └── Shutting down rethinkdb server'); return this.rdb_server.close(); } }); } function validateMigration() { // check that `${project}_internal` exists const project = this.options.project_name; const internalNotFound = `Database named '${project}_internal' wasn't found`; const tablesHaveHzPrefix = `Some tables in ${project} have an hz_ prefix`; const checkForHzTables = r.db('rethinkdb') .table('table_config') .filter({ db: project })('name') .contains((x) => x.match('^hz_')) .branch(r.error(tablesHaveHzPrefix), true); const waitForCollections = r.db(`${project}_internal`) .table('collections') .wait({ timeout: 30 }) .do(() => r.db(project).tableList()) .forEach((tableName) => r.db(project).table(tableName).wait({ timeout: 30 }) ); return Promise.resolve().then(() => { white('Validating current schema version'); return r.dbList().contains(`${project}_internal`) .branch(true, r.error(internalNotFound)) .do(() => checkForHzTables) .do(() => waitForCollections) .run(this.conn) .then(() => green(' └── Pre-2.0 schema found')) .catch((e) => { if (e.msg === internalNotFound) { throw new NiceError(e.msg, { description: `\ This could happen if you don't have a Horizon app in this database, or if \ you've already migrated this database to the v2.0 format.`, }); } else if (e.msg === tablesHaveHzPrefix) { throw new NiceError(e.msg, { description: `This could happen if you've already migrated \ this database to the v2.0 format.`, }); } else { throw e; } }); }); } function makeBackup() { // shell out to rethinkdb dump const rdbHost = this.options.rdb_host; const rdbPort = this.options.rdb_port; if (this.options.skip_backup) { return Promise.resolve(); } white('Backing up rethinkdb_data directory'); if (this.options.nonportable_backup) { return nonportableBackup(); } return procPromise('rethinkdb', [ 'dump', '--connect', `${rdbHost}:${rdbPort}`, ]).then(() => { green(' └── Backup completed'); }).catch((e) => { if (e.message.match(/Python driver/)) { throw new NiceError('The RethinkDB Python driver is not installed.', { description: `Before we migrate to the v2.0 format, we should do a \ backup of your RethinkDB database in case anything goes wrong. Unfortunately, \ we can't use the rethinkdb dump command to do a backup because you don't have \ the RethinkDB Python driver installed on your system.`, suggestions: [ `Install the Python driver with the instructions found at: \ http://www.rethinkdb.com/docs/install-drivers/python/`, `Pass the --nonportable-backup flag to hz migrate. This flag uses \ the tar command to make a backup, but the backup is not safe to use on \ another machine or to create replicas from. This option should not be used \ if RethinkDB is currently running. It should also not be used if the \ rethinkdb_data/ directory is not in the current directory.`, ] }); } else { throw e; } }); } function nonportableBackup() { // Uses tar to do an unsafe backup const timestamp = new Date().toISOString().replace(/:/g, '_'); return procPromise('tar', [ '-zcvf', // gzip, compress, verbose, filename is... `rethinkdb_data.nonportable-backup.${timestamp}.tar.gz`, 'rethinkdb_data', // directory to back up ]).then(() => { green(' └── Nonportable backup completed'); }); } function renameUserTables() { // for each table listed in ${project}_internal.collections // rename the table name to the collection name const project = this.options.project_name; return Promise.resolve().then(() => { white('Removing suffix from user tables'); return r.db(`${project}_internal`).wait({ timeout: 30 }). do(() => r.db(`${project}_internal`).table('collections') .forEach((collDoc) => r.db('rethinkdb').table('table_config') .filter({ db: project, name: collDoc('table') }) .update({ name: collDoc('id') })) ).run(this.conn) .then(() => green(' └── Suffixes removed')); }); } function moveInternalTables() { // find project_internal // move all tables from ${project}_internal.${table} to ${project}.hz_${table} // - except for users, don't add hz_prefix, but move its db const project = this.options.project_name; return Promise.resolve().then(() => { white(`Moving internal tables from ${project}_internal to ${project}`); return r.db('rethinkdb').table('table_config') .filter({ db: `${project}_internal` }) .update((table) => ({ db: project, name: r.branch( table('name').ne('users'), r('hz_').add(table('name')), 'users'), })).run(this.conn) .then(() => green(' ├── Internal tables moved')); }).then(() => { // delete project_internal green(` └── Deleting empty "${project}_internal" database`); return r.dbDrop(`${project}_internal`).run(this.conn); }); } function renameIndices() { // for each user $table in ${project} // for each index in ${table} // parse the old name into array of field names. // rename to `hz_${JSON.stringify(fields)}` const project = this.options.project_name; return Promise.resolve().then(() => { white('Renaming indices to new JSON format'); return r.db(project).tableList().forEach((tableName) => r.db(project).table(tableName).indexList().forEach((indexName) => r.db(project).table(tableName) .indexRename(indexName, rename(indexName)) ) ).run(this.conn) .then(() => green(' └── Indices renamed.')); }); function rename(name) { // ReQL to rename the index name to the new format const initialState = { escaped: false, field: '', fields: [ ], }; return name.split('') .fold(initialState, (acc, c) => r.branch( acc('escaped'), acc.merge({ escaped: false, field: acc('field').add(c), }), c.eq('\\'), acc.merge({ escaped: true }), c.eq('_'), acc.merge({ fields: acc('fields').append(acc('field')), field: '', }), acc.merge({ field: acc('field').add(c) }) ) ).do((state) => // last field needs to be appended to running list state('fields').append(state('field')) // wrap each field in an array .map((field) => [ field ]) ) .toJSON() .do((x) => r('hz_').add(x)); } } function rewriteHzCollectionDocs() { // for each document in ${project}.hz_collections // delete the table field const project = this.options.project_name; return Promise.resolve().then(() => { white('Rewriting hz_collections to new format'); return r.db(project).table('hz_collections') .update({ table: r.literal() }) .run(this.conn); }).then(() => green(' ├── "table" field removed')) .then(() => r.db(project).table('hz_collections') .insert({ id: 'users' }) .run(this.conn)) .then(() => green(' ├── Added document for "users" table')) .then(() => r.db(project).table('hz_collections') .insert({ id: 'hz_metadata', version: VERSION_2_0 }) .run(this.conn)) .then(() => green(' └── Adding the metadata document with schema version:' + `${JSON.stringify(VERSION_2_0)}`)); } function exportNewSchema() { // Import and run schema save process, giving it a different // filename than schema.toml const timestamp = new Date().toISOString().replace(/:/g, '_'); return accessAsync('.hz/schema.toml', fs.R_OK | fs.F_OK) .then(() => `.hz/schema.toml.migrated.${timestamp}`) .catch(() => '.hz/schema.toml') // if no schema.toml .then((schemaFile) => { white(`Exporting the new schema to ${schemaFile}`); return runSaveCommand({ rdb_host: this.options.rdb_host, rdb_port: this.options.rdb_port, rdb_user: this.options.rdb_user, rdb_password: this.options.rdb_password, out_file: schemaFile, project_name: this.options.project_name, }); }).then(() => green(' └── Schema exported')); } module.exports = { run, description: 'migrate an older version of horizon to a newer one', }; ================================================ FILE: cli/src/schema.js ================================================ 'use strict'; const horizon_server = require('@horizon/server'); const horizon_index = require('@horizon/server/src/metadata/index'); const horizon_metadata = require('@horizon/server/src/metadata/metadata'); const config = require('./utils/config'); const interrupt = require('./utils/interrupt'); const start_rdb_server = require('./utils/start_rdb_server'); const parse_yes_no_option = require('./utils/parse_yes_no_option'); const change_to_project_dir = require('./utils/change_to_project_dir'); const initialize_joi = require('./utils/initialize_joi'); const fs = require('fs'); const Joi = require('joi'); const path = require('path'); const argparse = require('argparse'); const toml = require('toml'); const r = horizon_server.r; const create_collection = horizon_metadata.create_collection; const initialize_metadata = horizon_metadata.initialize_metadata; initialize_joi(Joi); const parseArguments = (args) => { const parser = new argparse.ArgumentParser({ prog: 'hz schema' }); const subparsers = parser.addSubparsers({ title: 'subcommands', dest: 'subcommand_name', }); const apply = subparsers.addParser('apply', { addHelp: true }); const save = subparsers.addParser('save', { addHelp: true }); // Set options shared between both subcommands [ apply, save ].map((subcmd) => { subcmd.addArgument([ 'project_path' ], { type: 'string', nargs: '?', help: 'Change to this directory before serving' }); subcmd.addArgument([ '--project-name', '-n' ], { type: 'string', action: 'store', metavar: 'NAME', help: 'Name of the Horizon Project server' }); subcmd.addArgument([ '--connect', '-c' ], { type: 'string', metavar: 'HOST:PORT', help: 'Host and port of the RethinkDB server to connect to.' }); subcmd.addArgument([ '--rdb-timeout' ], { type: 'int', metavar: 'TIMEOUT', help: 'Timeout period in seconds for the RethinkDB connection to be opened' }); subcmd.addArgument([ '--rdb-user' ], { type: 'string', metavar: 'USER', help: 'RethinkDB User' }); subcmd.addArgument([ '--rdb-password' ], { type: 'string', metavar: 'PASSWORD', help: 'RethinkDB Password' }); subcmd.addArgument([ '--start-rethinkdb' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Start up a RethinkDB server in the current directory' }); subcmd.addArgument([ '--debug' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Enable debug logging.' }); }); // Options exclusive to HZ SCHEMA APPLY apply.addArgument([ '--update' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Only add new items and update existing, no removal.' }); apply.addArgument([ '--force' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Allow removal of existing collections.' }); apply.addArgument([ 'schema_file' ], { type: 'string', metavar: 'SCHEMA_FILE_PATH', help: 'File to get the horizon schema from, use "-" for stdin.' }); // Options exclusive to HZ SCHEMA SAVE save.addArgument([ '--out-file', '-o' ], { type: 'string', metavar: 'PATH', defaultValue: '.hz/schema.toml', help: 'File to write the horizon schema to, defaults to .hz/schema.toml.' }); return parser.parseArgs(args); }; const schema_schema = Joi.object().unknown(false).keys({ collections: Joi.object().unknown(true).pattern(/.*/, Joi.object().unknown(false).keys({ indexes: Joi.array().items( Joi.alternatives( Joi.string(), Joi.object().unknown(false).keys({ fields: Joi.array().items(Joi.array().items(Joi.string())).required(), }) ) ).optional().default([ ]), }) ).optional().default({ }), groups: Joi.object().unknown(true).pattern(/.*/, Joi.object().keys({ rules: Joi.object().unknown(true).pattern(/.*/, Joi.object().unknown(false).keys({ template: Joi.string().required(), validator: Joi.string().optional(), }) ).optional().default({ }), }) ).optional().default({ }), }); // Preserved for interpreting old schemas const v1_0_name_to_fields = (name) => { let escaped = false; let field = ''; const fields = [ ]; for (const c of name) { if (escaped) { if (c !== '\\' && c !== '_') { throw new Error(`Unexpected index name: "${name}"`); } escaped = false; field += c; } else if (c === '\\') { escaped = true; } else if (c === '_') { fields.push(field); field = ''; } else { field += c; } } if (escaped) { throw new Error(`Unexpected index name: "${name}"`); } fields.push([ field ]); return fields; }; const parse_schema = (schema_toml) => { const parsed = Joi.validate(toml.parse(schema_toml), schema_schema); const schema = parsed.value; if (parsed.error) { throw parsed.error; } const collections = [ ]; for (const name in schema.collections) { collections.push({ id: name, indexes: schema.collections[name].indexes.map((index) => { if (typeof index === 'string') { return { fields: v1_0_name_to_fields(index), multi: false, geo: false }; } else { return { fields: index.fields, multi: false, geo: false }; } }), }); } // Make sure the 'users' collection is present, as some things depend on // its existence. if (!schema.collections || !schema.collections.users) { collections.push({ id: 'users', indexes: [ ] }); } const groups = [ ]; for (const name in schema.groups) { groups.push(Object.assign({ id: name }, schema.groups[name])); } return { groups, collections }; }; const processApplyConfig = (parsed) => { let options, in_file; options = config.default_options(); options = config.merge_options(options, config.read_from_config_file(parsed.project_path)); options = config.merge_options(options, config.read_from_env()); options = config.merge_options(options, config.read_from_flags(parsed)); if (parsed.schema_file === '-') { in_file = process.stdin; } else { in_file = fs.createReadStream(parsed.schema_file, { flags: 'r' }); } if (options.project_name === null) { options.project_name = path.basename(path.resolve(options.project_path)); } return { subcommand_name: 'apply', start_rethinkdb: options.start_rethinkdb, rdb_host: options.rdb_host, rdb_port: options.rdb_port, rdb_user: options.rdb_user || undefined, rdb_password: options.rdb_password || undefined, project_name: options.project_name, project_path: options.project_path, debug: options.debug, update: parse_yes_no_option(parsed.update), force: parse_yes_no_option(parsed.force), in_file, }; }; const processSaveConfig = (parsed) => { let options, out_file; options = config.default_options(); options.start_rethinkdb = true; options = config.merge_options(options, config.read_from_config_file(parsed.project_path)); options = config.merge_options(options, config.read_from_env()); options = config.merge_options(options, config.read_from_flags(parsed)); if (parsed.out_file === '-') { out_file = process.stdout; } else { out_file = parsed.out_file; } if (options.project_name === null) { options.project_name = path.basename(path.resolve(options.project_path)); } return { subcommand_name: 'save', start_rethinkdb: options.start_rethinkdb, rdb_host: options.rdb_host, rdb_port: options.rdb_port, rdb_user: options.rdb_user || undefined, rdb_password: options.rdb_password || undefined, project_name: options.project_name, project_path: options.project_path, debug: options.debug, out_file, }; }; const schema_to_toml = (collections, groups) => { const res = [ '# This is a TOML document' ]; for (const c of collections) { res.push(''); res.push(`[collections.${c.id}]`); c.indexes.forEach((index) => { const info = horizon_index.name_to_info(index); res.push(`[[collections.${c.id}.indexes]]`); res.push(`fields = ${JSON.stringify(info.fields)}`); }); } for (const g of groups) { res.push(''); res.push(`[groups.${g.id}]`); if (g.rules) { for (const key in g.rules) { const template = g.rules[key].template; const validator = g.rules[key].validator; res.push(`[groups.${g.id}.rules.${key}]`); res.push(`template = ${JSON.stringify(template)}`); if (validator) { res.push(`validator = ${JSON.stringify(validator)}`); } } } } res.push(''); return res.join('\n'); }; const runApplyCommand = (options) => { let conn, schema, rdb_server; let obsolete_collections = [ ]; const db = options.project_name; const cleanup = () => Promise.all([ conn ? conn.close() : Promise.resolve(), rdb_server ? rdb_server.close() : Promise.resolve(), ]); interrupt.on_interrupt(() => cleanup()); return Promise.resolve().then(() => { if (options.start_rethinkdb) { change_to_project_dir(options.project_path); } return new Promise((resolve, reject) => { let schema_toml = ''; options.in_file.on('data', (buffer) => (schema_toml += buffer)); options.in_file.on('end', () => resolve(schema_toml)); options.in_file.on('error', reject); }); }).then((schema_toml) => { schema = parse_schema(schema_toml); if (options.start_rethinkdb) { return start_rdb_server({ quiet: !options.debug }).then((server) => { rdb_server = server; options.rdb_host = 'localhost'; options.rdb_port = server.driver_port; }); } }).then(() => r.connect({ host: options.rdb_host, port: options.rdb_port, user: options.rdb_user, password: options.rdb_password, timeout: options.rdb_timeout }) ).then((rdb_conn) => { conn = rdb_conn; return initialize_metadata(db, conn); }).then((initialization_result) => { if (initialization_result.tables_created) { console.log('Initialized new application metadata.'); } // Wait for metadata tables to be writable return r.expr([ 'hz_collections', 'hz_groups' ]) .forEach((table) => r.db(db).table(table) .wait({ waitFor: 'ready_for_writes', timeout: 30 })) .run(conn); }).then(() => { // Error if any collections will be removed if (!options.update) { return r.db(db).table('hz_collections') .filter((row) => row('id').match('^hz_').not()) .getField('id') .coerceTo('array') .setDifference(schema.collections.map((c) => c.id)) .run(conn) .then((res) => { if (!options.force && res.length > 0) { throw new Error('Run with "--force" to continue.\n' + 'These collections would be removed along with their data:\n' + `${res.join(', ')}`); } obsolete_collections = res; }); } }).then(() => { if (options.update) { // Update groups return Promise.all(schema.groups.map((group) => { const literal_group = JSON.parse(JSON.stringify(group)); Object.keys(literal_group.rules).forEach((key) => { literal_group.rules[key] = r.literal(literal_group.rules[key]); }); return r.db(db).table('hz_groups') .get(group.id).replace((old_row) => r.branch(old_row.eq(null), group, old_row.merge(literal_group))) .run(conn).then((res) => { if (res.errors) { throw new Error(`Failed to update group: ${res.first_error}`); } }); })); } else { // Replace and remove groups const groups_obj = { }; schema.groups.forEach((g) => { groups_obj[g.id] = g; }); return Promise.all([ r.expr(groups_obj).do((groups) => r.db(db).table('hz_groups') .replace((old_row) => r.branch(groups.hasFields(old_row('id')), old_row, null)) ).run(conn).then((res) => { if (res.errors) { throw new Error(`Failed to write groups: ${res.first_error}`); } }), r.db(db).table('hz_groups') .insert(schema.groups, { conflict: 'replace' }) .run(conn).then((res) => { if (res.errors) { throw new Error(`Failed to write groups: ${res.first_error}`); } }), ]); } }).then(() => { // Ensure all collections exist and remove any obsolete collections const promises = [ ]; for (const c of schema.collections) { promises.push( create_collection(db, c.id, conn).then((res) => { if (res.error) { throw new Error(res.error); } })); } for (const c of obsolete_collections) { promises.push( r.db(db) .table('hz_collections') .get(c) .delete({ returnChanges: 'always' })('changes')(0) .do((res) => r.branch(res.hasFields('error'), res, res('old_val').eq(null), res, r.db(db).tableDrop(res('old_val')('id')).do(() => res))) .run(conn).then((res) => { if (res.error) { throw new Error(res.error); } })); } return Promise.all(promises); }).then(() => { const promises = [ ]; // Ensure all indexes exist for (const c of schema.collections) { for (const info of c.indexes) { const name = horizon_index.info_to_name(info); promises.push( r.branch(r.db(db).table(c.id).indexList().contains(name), { }, r.db(db).table(c.id).indexCreate(name, horizon_index.info_to_reql(info), { geo: Boolean(info.geo), multi: (info.multi !== false) })) .run(conn) .then((res) => { if (res.errors) { throw new Error(`Failed to create index ${name} ` + `on collection ${c.id}: ${res.first_error}`); } })); } } // Remove obsolete indexes if (!options.update) { for (const c of schema.collections) { const names = c.indexes.map(horizon_index.info_to_name); promises.push( r.db(db).table(c.id).indexList().filter((name) => name.match('^hz_')) .setDifference(names) .forEach((name) => r.db(db).table(c.id).indexDrop(name)) .run(conn) .then((res) => { if (res.errors) { throw new Error('Failed to remove old indexes ' + `on collection ${c.id}: ${res.first_error}`); } })); } } return Promise.all(promises); }).then(cleanup).catch((err) => cleanup().then(() => { throw err; })); }; const file_exists = (filename) => { try { fs.accessSync(filename); } catch (e) { return false; } return true; }; const runSaveCommand = (options) => { let conn, rdb_server; const db = options.project_name; const cleanup = () => Promise.all([ conn ? conn.close() : Promise.resolve(), rdb_server ? rdb_server.close() : Promise.resolve(), ]); interrupt.on_interrupt(() => cleanup()); return Promise.resolve().then(() => { if (options.start_rethinkdb) { change_to_project_dir(options.project_path); } }).then(() => { if (options.start_rethinkdb) { return start_rdb_server({ quiet: !options.debug }).then((server) => { rdb_server = server; options.rdb_host = 'localhost'; options.rdb_port = server.driver_port; }); } }).then(() => r.connect({ host: options.rdb_host, port: options.rdb_port, user: options.rdb_user, password: options.rdb_password, timeout: options.rdb_timeout }) ).then((rdb_conn) => { conn = rdb_conn; return r.db(db).wait({ waitFor: 'ready_for_reads', timeout: 30 }).run(conn); }).then(() => r.object('collections', r.db(db).table('hz_collections') .filter((row) => row('id').match('^hz_').not()) .coerceTo('array') .map((row) => row.merge({ indexes: r.db(db).table(row('id')).indexList() })), 'groups', r.db(db).table('hz_groups').coerceTo('array')) .run(conn) ).then((res) => new Promise((resolve) => { // Only rename old file if saving to default .hz/schema.toml if (options.out_file === '.hz/schema.toml' && file_exists(options.out_file)) { // Rename existing file to have the current time appended to its name const oldPath = path.resolve(options.out_file); const newPath = `${path.resolve(options.out_file)}.${new Date().toISOString()}`; fs.renameSync(oldPath, newPath); } const output = (options.out_file === '-') ? process.stdout : fs.createWriteStream(options.out_file, { flags: 'w', defaultEncoding: 'utf8' }); // Output toml_str to schema.toml const toml_str = schema_to_toml(res.collections, res.groups); output.end(toml_str, resolve); }) ).then(cleanup).catch((err) => cleanup().then(() => { throw err; })); }; const processConfig = (options) => { // Determine if we are saving or applying and use appropriate config processing switch (options.subcommand_name) { case 'apply': return processApplyConfig(options); case 'save': return processSaveConfig(options); default: throw new Error(`Unrecognized schema subcommand: "${options.subcommand_name}"`); } }; // Avoiding cyclical depdendencies module.exports = { run: (args) => Promise.resolve().then(() => { const options = processConfig(parseArguments(args)); // Determine if we are saving or applying and use appropriate run function switch (options.subcommand_name) { case 'apply': return runApplyCommand(options); case 'save': return runSaveCommand(options); default: throw new Error(`Unrecognized schema subcommand: "${options.subcommand_name}"`); } }), description: 'Apply and save the schema from a horizon database', processApplyConfig, runApplyCommand, runSaveCommand, parse_schema, }; ================================================ FILE: cli/src/serve.js ================================================ 'use strict'; const chalk = require('chalk'); const crypto = require('crypto'); const fs = require('fs'); const get_type = require('mime-types').contentType; const http = require('http'); const https = require('https'); const open = require('open'); const path = require('path'); const argparse = require('argparse'); const url = require('url'); const config = require('./utils/config'); const start_rdb_server = require('./utils/start_rdb_server'); const change_to_project_dir = require('./utils/change_to_project_dir'); const NiceError = require('./utils/nice_error.js'); const interrupt = require('./utils/interrupt'); const schema = require('./schema'); const horizon_server = require('@horizon/server'); const logger = horizon_server.logger; const TIMEOUT_30_SECONDS = 30 * 1000; const default_rdb_host = 'localhost'; const default_rdb_port = 28015; const default_rdb_timeout = 20; const parseArguments = (args) => { const parser = new argparse.ArgumentParser({ prog: 'hz serve' }); parser.addArgument([ 'project_path' ], { type: 'string', nargs: '?', help: 'Change to this directory before serving' }); parser.addArgument([ '--project-name', '-n' ], { type: 'string', action: 'store', metavar: 'NAME', help: 'Name of the Horizon project. Determines the name of ' + 'the RethinkDB database that stores the project data.' }); parser.addArgument([ '--bind', '-b' ], { type: 'string', action: 'append', metavar: 'HOST', help: 'Local hostname to serve horizon on (repeatable).' }); parser.addArgument([ '--port', '-p' ], { type: 'int', metavar: 'PORT', help: 'Local port to serve horizon on.' }); parser.addArgument([ '--connect', '-c' ], { type: 'string', metavar: 'HOST:PORT', help: 'Host and port of the RethinkDB server to connect to.' }); parser.addArgument([ '--rdb-timeout' ], { type: 'int', metavar: 'TIMEOUT', help: 'Timeout period in seconds for the RethinkDB connection to be opened' }); parser.addArgument([ '--rdb-user' ], { type: 'string', metavar: 'USER', help: 'RethinkDB User' }); parser.addArgument([ '--rdb-password' ], { type: 'string', metavar: 'PASSWORD', help: 'RethinkDB Password' }); parser.addArgument([ '--key-file' ], { type: 'string', metavar: 'PATH', help: 'Path to the key file to use, defaults to "./horizon-key.pem".' }); parser.addArgument([ '--cert-file' ], { type: 'string', metavar: 'PATH', help: 'Path to the cert file to use, defaults to "./horizon-cert.pem".' }); parser.addArgument([ '--token-secret' ], { type: 'string', metavar: 'SECRET', help: 'Key for signing jwts' }); parser.addArgument([ '--allow-unauthenticated' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Whether to allow unauthenticated Horizon connections.' }); parser.addArgument([ '--allow-anonymous' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Whether to allow anonymous Horizon connections.' }); parser.addArgument([ '--max-connections' ], { type: 'int', metavar: 'MAX_CONNECTIONS', help: 'Maximum number of simultaneous connections server will accept.' }); parser.addArgument([ '--debug' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Enable debug logging.' }); parser.addArgument([ '--secure' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Serve secure websockets, requires --key-file and ' + '--cert-file if true, on by default.' }); parser.addArgument([ '--start-rethinkdb' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Start up a RethinkDB server in the current directory' }); parser.addArgument([ '--auto-create-collection' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Create collections used by requests if they do not exist.' }); parser.addArgument([ '--auto-create-index' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Create indexes used by requests if they do not exist.' }); parser.addArgument([ '--permissions' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', help: 'Enables or disables checking permissions on requests.' }); parser.addArgument([ '--serve-static' ], { type: 'string', metavar: 'PATH', nargs: '?', constant: './dist', help: 'Serve static files from a directory, defaults to "./dist".' }); parser.addArgument([ '--dev' ], { action: 'storeTrue', help: 'Runs the server in development mode, this sets ' + '--secure=no, ' + '--permissions=no, ' + '--auto-create-collection=yes, ' + '--auto-create-index=yes, ' + '--start-rethinkdb=yes, ' + '--allow-unauthenticated=yes, ' + '--allow-anonymous=yes ' + 'and --serve-static=./dist.' }); parser.addArgument([ '--schema-file' ], { type: 'string', metavar: 'SCHEMA_FILE_PATH', help: 'Path to the schema file to use, ' + 'will attempt to apply schema before starting Horizon server".' }); parser.addArgument([ '--auth' ], { type: 'string', action: 'append', metavar: 'PROVIDER,ID,SECRET', defaultValue: [ ], help: 'Auth provider and options comma-separated, e.g. "facebook,,".' }); parser.addArgument([ '--auth-redirect' ], { type: 'string', metavar: 'URL', help: 'The URL to redirect to upon completed authentication, defaults to "/".' }); parser.addArgument([ '--access-control-allow-origin' ], { type: 'string', metavar: 'URL', help: 'The URL of the host that can access auth settings, defaults to "".' }); parser.addArgument([ '--open' ], { action: 'storeTrue', help: 'Open index.html in the static files folder once Horizon is ready to' + ' receive connections' }); return parser.parseArgs(args); }; // Simple file server. 404s if file not found, 500 if file error, // otherwise serve it with a mime-type suggested by its file extension. const serve_file = (filePath, res) => { fs.access(filePath, fs.R_OK | fs.F_OK, (exists) => { if (exists) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end(`File "${filePath}" not found\n`); } else { fs.lstat(filePath, (err, stats) => { if (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end(`${err}\n`); } else if (stats.isFile()) { fs.readFile(filePath, 'binary', (err2, file) => { if (err2) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end(`${err2}\n`); } else { const type = get_type(path.extname(filePath)) || false; if (type) { res.writeHead(200, { 'Content-Type': type }); } else { res.writeHead(200); } res.end(file, 'binary'); } }); } else if (stats.isDirectory()) { serve_file(path.join(filePath, 'index.html'), res); } }); } }); }; const file_server = (distDir) => (req, res) => { const reqPath = url.parse(req.url).pathname; // Serve client files directly if (reqPath === '/' || reqPath === '') { serve_file(path.join(distDir, 'index.html'), res); } else if (!reqPath.match(/\/horizon\/.*$/)) { // All other static files come from the dist directory serve_file(path.join(distDir, reqPath), res); } // Fall through otherwise. Should be handled by horizon server }; const initialize_servers = (ctor, opts) => { const servers = [ ]; let numReady = 0; return new Promise((resolve, reject) => { opts.bind.forEach((host) => { const srv = ctor().listen(opts.port, host); servers.push(srv); if (opts.serve_static) { if (opts.serve_static === 'dist') { // do nothing, this is the default } else if (opts.project_path !== '.') { const pth = path.join(opts.project_path, opts.serve_static); console.info(`Static files being served from ${pth}`); } else { console.info(`Static files being served from ${opts.serve_static}`); } srv.on('request', file_server(opts.serve_static)); } else { srv.on('request', (req, res) => { res.writeHead(404); res.end('404 Not Found'); }); } srv.on('listening', () => { const protocol = opts.secure ? 'https' : 'http'; console.info(`App available at ${protocol}://${srv.address().address}:` + `${srv.address().port}`); if (++numReady === servers.length) { resolve(servers); } }); srv.on('error', (err) => { reject(new Error(`HTTP${opts.secure ? 'S' : ''} server: ${err}`)); }); }); }); }; const create_insecure_servers = (opts) => { if (!opts._dev_flag_used) { console.error(chalk.red.bold('WARNING: Serving app insecurely.')); } return initialize_servers(() => new http.Server(), opts); }; const read_cert_file = (file, type) => { try { return fs.readFileSync(path.resolve(file)); } catch (err) { const wasDefault = file.endsWith(`horizon-${type}.pem`); let description; const suggestions = [ `If you're running horizon for the first time, we recommend \ running horizon like ${chalk.white('hz serve --dev')} to get started without \ having to configure certificates.`, ]; if (wasDefault) { suggestions.push( `If you have a ${type} file you'd like to use but they aren't in the \ default location, pass them in with the \ ${chalk.white(`hz serve --${type}-file`)} option.`, `You can explicitly disable security by passing \ ${chalk.white('--secure=no')} to ${chalk.white('hz serve')}.`, `You can generate a cert and key file for development by using the \ ${chalk.white('hz create-cert')} command. Note that these certs won't be \ signed by a certificate authority, so you will need to explicitly authorize \ them in your browser.` ); description = `In order to run the server in secure mode (the default), \ Horizon needs both a certificate file and a key file to encrypt websockets. \ By default, it looks for horizon-key.pem and horizon-cert.pem \ files in the current directory.`; } else { // They supplied a cert or key file, so don't give the long // explanation and irrelevant suggestions. suggestions.unshift(`See if the ${type} filename was misspelled.`); description = null; } throw new NiceError( `Could not access the ${type} file ${file}`, { description, suggestions, }); } }; const create_secure_servers = (opts) => { const cert = read_cert_file(opts.cert_file, 'cert'); const key = read_cert_file(opts.key_file, 'key'); return initialize_servers(() => new https.Server({ key, cert }), opts); }; // Command-line flags have the highest precedence, followed by environment variables, // then the config file, and finally the default values. const processConfig = (parsed) => { let options; options = config.default_options(); options = config.merge_options(options, config.read_from_config_file(parsed.project_path)); options = config.merge_options(options, config.read_from_secrets_file(parsed.project_path)); options = config.merge_options(options, config.read_from_env()); options = config.merge_options(options, config.read_from_flags(parsed)); if (options.project_name === null) { options.project_name = path.basename(path.resolve(options.project_path)); } if (options.bind.indexOf('all') !== -1) { options.bind = [ '0.0.0.0' ]; } if (!options.rdb_host) { options.rdb_host = default_rdb_host; } if (!options.rdb_port) { options.rdb_port = default_rdb_port; } if (!options.rdb_timeout) { options.rdb_timeout = default_rdb_timeout; } return options; }; const start_horizon_server = (http_servers, opts) => new horizon_server.Server(http_servers, { auto_create_collection: opts.auto_create_collection, auto_create_index: opts.auto_create_index, permissions: opts.permissions, project_name: opts.project_name, access_control_allow_origin: opts.access_control_allow_origin, auth: { token_secret: opts.token_secret, allow_unauthenticated: opts.allow_unauthenticated, allow_anonymous: opts.allow_anonymous, success_redirect: opts.auth_redirect, failure_redirect: opts.auth_redirect, }, rdb_host: opts.rdb_host, rdb_port: opts.rdb_port, rdb_user: opts.rdb_user || null, rdb_password: opts.rdb_password || null, rdb_timeout: opts.rdb_timeout || null, max_connections: opts.max_connections || null, }); // `interruptor` is meant for use by tests to stop the server without relying on SIGINT const run = (args, interruptor) => { let opts, http_servers, hz_server, rdb_server; const old_log_level = logger.level; const cleanup = () => { logger.level = old_log_level; return Promise.all([ hz_server ? hz_server.close() : Promise.resolve(), rdb_server ? rdb_server.close() : Promise.resolve(), http_servers ? Promise.all(http_servers.map((s) => new Promise((resolve) => s.close(resolve)))) : Promise.resolve(), ]); }; interrupt.on_interrupt(() => cleanup()); return Promise.resolve().then(() => { opts = processConfig(parseArguments(args)); logger.level = opts.debug ? 'debug' : 'warn'; if (!opts.secure && opts.auth && Array.from(Object.keys(opts.auth)).length > 0) { logger.warn('Authentication requires that the server be accessible via HTTPS. ' + 'Either specify "secure=true" or use a reverse proxy.'); } change_to_project_dir(opts.project_path); if (opts.secure) { return create_secure_servers(opts); } else { return create_insecure_servers(opts); } }).then((servers) => { http_servers = servers; if (opts.start_rethinkdb) { return start_rdb_server().then((server) => { rdb_server = server; // Don't need to check for host, always localhost. opts.rdb_host = 'localhost'; opts.rdb_port = server.driver_port; console.log('RethinkDB'); console.log(` ├── Admin interface: http://localhost:${server.http_port}`); console.log(` └── Drivers can connect to port ${server.driver_port}`); }); } }).then(() => { // Ensure schema from schema.toml file is set if (opts.schema_file) { console.log(`Ensuring schema "${opts.schema_file}" is applied`); try { fs.accessAsync(opts.schema_file, fs.R_OK | fs.F_OK); } catch (e) { console.error( chalk.yellow.bold('No .hz/schema.toml file found')); return; } const schemaOptions = schema.processApplyConfig({ project_name: opts.project_name, schema_file: opts.schema_file, start_rethinkdb: false, connect: `${opts.rdb_host}:${opts.rdb_port}`, update: true, force: false, }); return schema.runApplyCommand(schemaOptions); } }).then(() => { console.log('Starting Horizon...'); hz_server = start_horizon_server(http_servers, opts); return new Promise((resolve, reject) => { const timeoutObject = setTimeout(() => { reject(new Error('Horizon failed to start after 30 seconds.\n' + 'Try running hz serve again with the --debug flag')); }, TIMEOUT_30_SECONDS); hz_server.ready().then(() => { clearTimeout(timeoutObject); console.log(chalk.green.bold('🌄 Horizon ready for connections')); resolve(hz_server); }).catch(reject); }); }).then(() => { if (opts.auth) { for (const name in opts.auth) { const provider = horizon_server.auth[name]; if (!provider) { throw new Error(`Unrecognized auth provider "${name}"`); } hz_server.add_auth_provider(provider, Object.assign({}, { path: name }, opts.auth[name])); } } }).then(() => { // Automatically open up index.html in the `dist` directory only if // `--open` flag specified and an index.html exists in the directory. if (opts.open && opts.serve_static) { try { // Check if index.html exists and readable in serve static_static directory fs.accessSync(`${opts.serve_static}/index.html`, fs.R_OK | fs.F_OK); // Determine scheme from options const scheme = opts.secure ? 'https://' : 'http://'; // Open up index.html in default browser console.log('Attempting open of index.html in default browser'); open(`${scheme}${opts.bind}:${opts.port}/index.html`); } catch (open_err) { console.log(chalk.red('Error occurred while trying to open ' + `${opts.serve_static}/index.html`)); console.log(open_err); } } return Promise.race([ hz_server._interruptor.catch(() => { }), interruptor ? interruptor.catch(() => { }) : new Promise(() => { }), ]); }).then(cleanup).catch((err) => cleanup().then(() => { throw err; })); }; module.exports = { run, description: 'Serve a Horizon app', parseArguments, processConfig, }; ================================================ FILE: cli/src/utils/change_to_project_dir.js ================================================ 'use strict'; const is_directory = require('./is_directory'); module.exports = (project_path) => { if (is_directory(project_path)) { process.chdir(project_path); } else { throw new Error(`${project_path} is not a directory`); } if (!is_directory('.hz')) { const nice_path = (project_path === '.' ? 'this directory' : project_path); throw new Error(`${nice_path} doesn't contain an .hz directory`); } }; ================================================ FILE: cli/src/utils/check-project-name.js ================================================ 'use strict'; const path = require('path'); const basename = path.basename; const join = path.join; const fixableProjectName = /^[A-Za-z0-9_-]+$/; const unfixableChars = /[^A-Za-z0-9_-]/g; const dehyphenate = (name) => name.replace(/-/g, '_'); const shouldCreateDir = (prospectiveName, dirList) => { if (prospectiveName === '.' || prospectiveName == null || !fixableProjectName.test(prospectiveName)) { return false; } else if (dirList.indexOf(prospectiveName) === -1) { return true; } else { return false; } }; module.exports = (prospectiveName, cwd, dirList) => { let chdirTo = prospectiveName != null ? join(cwd, prospectiveName) : cwd; const createDir = shouldCreateDir(prospectiveName, dirList); if (prospectiveName === '.' || prospectiveName == null) { // eslint-disable-next-line no-param-reassign prospectiveName = basename(cwd); chdirTo = false; } if (fixableProjectName.test(prospectiveName)) { return { dirName: prospectiveName, projectName: dehyphenate(prospectiveName), chdirTo, createDir, }; } else { const invalids = prospectiveName.match(unfixableChars).join(''); throw new Error(`Invalid characters in project name: ${invalids}`); } }; ================================================ FILE: cli/src/utils/config.js ================================================ 'use strict'; const parse_yes_no_option = require('./parse_yes_no_option'); const NiceError = require('./nice_error.js'); const fs = require('fs'); const url = require('url'); const toml = require('toml'); const chalk = require('chalk'); const default_config_file = '.hz/config.toml'; const default_secrets_file = '.hz/secrets.toml'; const default_rdb_port = 28015; const make_default_options = () => ({ config: null, debug: false, // Default to current directory for path project_path: '.', // Default to current directory name for project name project_name: null, bind: [ 'localhost' ], port: 8181, start_rethinkdb: false, serve_static: null, open: false, secure: true, permissions: true, key_file: './horizon-key.pem', cert_file: './horizon-cert.pem', schema_file: null, auto_create_collection: false, auto_create_index: false, rdb_host: null, rdb_port: null, rdb_user: null, rdb_password: null, rdb_timeout: null, token_secret: null, allow_anonymous: false, allow_unauthenticated: false, auth_redirect: '/', access_control_allow_origin: '', max_connections: null, auth: { }, }); const default_options = make_default_options(); const yes_no_options = [ 'debug', 'secure', 'permissions', 'start_rethinkdb', 'auto_create_index', 'auto_create_collection', 'allow_unauthenticated', 'allow_anonymous' ]; const parse_connect = (connect, config) => { // support rethinkdb:// style connection uri strings // expects rethinkdb://host:port` at a minimum but can optionally take a user:pass and db // e.g. rethinkdb://user:pass@host:port/db const rdb_uri = url.parse(connect); if (rdb_uri.protocol === 'rethinkdb:') { if (rdb_uri.hostname) { config.rdb_host = rdb_uri.hostname; config.rdb_port = rdb_uri.port || default_rdb_port; // check for user/pass if (rdb_uri.auth) { const user_pass = rdb_uri.auth.split(':'); config.rdb_user = user_pass[0]; config.rdb_password = user_pass[1]; } // set the project name based on the db if (rdb_uri.path && rdb_uri.path.replace('/', '') !== '') { config.project_name = rdb_uri.path.replace('/', ''); } } else { throw new Error(`Expected --connect rethinkdb://HOST, but found "${connect}".`); } } else { // support legacy HOST:PORT connection strings const host_port = connect.split(':'); if (host_port.length === 1) { config.rdb_host = host_port[0]; config.rdb_port = default_rdb_port; } else if (host_port.length === 2) { config.rdb_host = host_port[0]; config.rdb_port = parseInt(host_port[1]); if (isNaN(config.rdb_port) || config.rdb_port < 0 || config.rdb_port > 65535) { throw new Error(`Invalid port: "${host_port[1]}".`); } } else { throw new Error(`Expected --connect HOST:PORT, but found "${connect}".`); } } }; const read_from_config_file = (project_path) => { const config = { auth: { } }; let fileData, configFilename, fileConfig; if (project_path) { configFilename = `${project_path}/${default_config_file}`; } else { configFilename = default_config_file; } try { fileData = fs.readFileSync(configFilename); } catch (err) { return config; } try { fileConfig = toml.parse(fileData); } catch (e) { if (e.name === 'SyntaxError') { throw new NiceError( `There was a syntax error when parsing ${configFilename}`, { description: `Something was wrong with the format of \ ${configFilename}, causing it not be a valid TOML file.`, src: { filename: configFilename, contents: fileData, line: e.line, column: e.column, }, suggestions: [ 'Check that all strings values have quotes around them', 'Check that key/val pairs use equals and not colons', 'See https://github.com/toml-lang/toml#user-content-spec', ], }); } else { throw e; } } for (const field in fileConfig) { if (field === 'connect') { parse_connect(fileConfig.connect, config); } else if (yes_no_options.indexOf(field) !== -1) { config[field] = parse_yes_no_option(fileConfig[field], field); } else if (default_options[field] !== undefined) { config[field] = fileConfig[field]; } else { throw new Error(`Unknown config parameter: "${field}".`); } } return config; }; const read_from_secrets_file = (projectPath) => { const config = { auth: { } }; let fileData, secretsFilename; if (projectPath) { secretsFilename = `${projectPath}/${default_secrets_file}`; } else { secretsFilename = default_secrets_file; } try { fileData = fs.readFileSync(secretsFilename); } catch (err) { return config; } const fileConfig = toml.parse(fileData); for (const field in fileConfig) { if (field === 'connect') { parse_connect(fileConfig.connect, config); } else if (yes_no_options.indexOf(field) !== -1) { config[field] = parse_yes_no_option(fileConfig[field], field); } else if (default_options[field] !== undefined) { config[field] = fileConfig[field]; } else { throw new Error(`Unknown config parameter: "${field}".`); } } return config; }; const env_regex = /^HZ_([A-Z]+([_]?[A-Z]+)*)$/; const read_from_env = () => { const config = { auth: { } }; for (const env_var in process.env) { const matches = env_regex.exec(env_var); if (matches && matches[1]) { const destVarName = matches[1].toLowerCase(); const varPath = destVarName.split('_'); const value = process.env[env_var]; if (destVarName === 'connect') { parse_connect(value, config); } else if (destVarName === 'bind') { config[destVarName] = value.split(','); } else if (varPath[0] === 'auth') { if (varPath.length !== 3) { console.log(`Ignoring malformed Horizon environment variable: "${env_var}", ` + 'should be HZ_AUTH_{PROVIDER}_{OPTION}.'); } else { config.auth[varPath[1]] = config.auth[varPath[1]] || { }; config.auth[varPath[1]][varPath[2]] = value; } } else if (yes_no_options.indexOf(destVarName) !== -1) { config[destVarName] = parse_yes_no_option(value, destVarName); } else if (default_options[destVarName] !== undefined) { config[destVarName] = value; } } } return config; }; // Handles reading configuration from the parsed flags const read_from_flags = (parsed) => { const config = { auth: { } }; // Dev mode if (parsed.dev) { config.access_control_allow_origin = '*'; config.allow_unauthenticated = true; config.allow_anonymous = true; config.secure = false; config.permissions = false; config.start_rethinkdb = true; config.auto_create_collection = true; config.auto_create_index = true; config.serve_static = 'dist'; config._dev_flag_used = true; if (parsed.start_rethinkdb === null || parsed.start_rethinkdb === undefined) { config._start_rethinkdb_implicit = true; } } for (const key in parsed) { if (key === 'auth' && parsed.auth != null) { // Auth options parsed.auth.forEach((auth_options) => { const params = auth_options.split(','); if (params.length !== 3) { throw new Error(`Expected --auth PROVIDER,ID,SECRET, but found "${auth_options}"`); } config.auth[params[0]] = { id: params[1], secret: params[2] }; }); } else if (key === 'connect' && parsed.connect != null) { // Normalize RethinkDB connection options parse_connect(parsed.connect, config); } else if (yes_no_options.indexOf(key) !== -1 && parsed[key] != null) { // Simple 'yes' or 'no' (or 'true' or 'false') flags config[key] = parse_yes_no_option(parsed[key], key); } else if (parsed[key] != null) { config[key] = parsed[key]; } } return config; }; const merge_options = (old_options, new_options) => { // Disable start_rethinkdb if it was enabled by dev mode but we already have a host if (new_options._start_rethinkdb_implicit) { if (old_options.rdb_host) { delete new_options.start_rethinkdb; } } else if (new_options.start_rethinkdb && new_options.rdb_host) { throw new Error('Cannot provide both --start-rethinkdb and --connect.'); } for (const key in new_options) { if (key === 'rdb_host') { old_options.start_rethinkdb = false; } if (key === 'auth') { for (const provider in new_options.auth) { old_options.auth[provider] = old_options.auth[provider] || { }; for (const field in new_options.auth[provider]) { old_options.auth[provider][field] = new_options.auth[provider][field]; } } } else { old_options[key] = new_options[key]; } } return old_options; }; module.exports = { default_config_file, default_secrets_file, default_options: make_default_options, read_from_config_file, read_from_secrets_file, read_from_env, read_from_flags, merge_options, parse_connect, }; ================================================ FILE: cli/src/utils/each_line_in_pipe.js ================================================ 'use strict'; module.exports = (pipe, callback) => { let buffer = ''; pipe.on('data', (data) => { buffer += data.toString(); let endline_pos = buffer.indexOf('\n'); while (endline_pos !== -1) { const line = buffer.slice(0, endline_pos); buffer = buffer.slice(endline_pos + 1); callback(line); endline_pos = buffer.indexOf('\n'); } }); }; ================================================ FILE: cli/src/utils/initialize_joi.js ================================================ 'use strict'; // Issues a dummy joi validation to force joi to initialize its scripts. // This is used because tests will mock the filesystem, and the lazy // `require`s done by joi will no longer work at that point. module.exports = (joi) => joi.validate('', joi.any().when('', { is: '', then: joi.any() })); ================================================ FILE: cli/src/utils/interrupt.js ================================================ 'use strict'; const handlers = [ ]; const on_interrupt = (cb) => { handlers.push(cb); }; const run_handlers = () => { if (handlers.length > 0) { return handlers.shift()().then(() => run_handlers); } }; const interrupt = () => { process.removeAllListeners('SIGTERM'); process.removeAllListeners('SIGINT'); process.on('SIGTERM', () => process.exit(1)); process.on('SIGINT', () => process.exit(1)); return run_handlers(); }; process.on('SIGTERM', interrupt); process.on('SIGINT', interrupt); module.exports = { on_interrupt, interrupt }; ================================================ FILE: cli/src/utils/is_directory.js ================================================ 'use strict'; const path = require('path'); const fs = require('fs'); module.exports = (dirname) => { try { return fs.statSync(path.resolve(dirname)).isDirectory(); } catch (e) { return false; } }; ================================================ FILE: cli/src/utils/nice_error.js ================================================ 'use strict'; /* A nice error type that allows you to associate a longer description, a source file and suggestions with it. */ const chalk = require('chalk'); class NiceError extends Error { constructor(message, options) { super(message); const opts = options || {}; this.description = opts.description || null; this.suggestions = opts.suggestions || null; // TODO: maybe allow multiple source locations and spans of text // instead of a single column offset this.src = (opts.src) ? Object.assign({}, opts.src) : null; } toString() { return this.message; } niceString(options) { const opts = options || {}; const cSize = opts.contextSize != null ? opts.contextSize : 2; const results = [ this.message ]; if (this.description) { results.push('', this.description); } if (this.src != null) { const formattedSrc = NiceError._formatContext( this.src.contents, this.src.line, this.src.column, cSize ); if (formattedSrc.length > 0) { results.push(`\nIn ${this.src.filename}, ` + `line ${this.src.line}, ` + `column ${this.src.column}:`); results.push.apply(results, formattedSrc); } } if (this.suggestions) { results.push( '', // extra newline before suggestions chalk.red( this.suggestions.length > 1 ? 'Suggestions:' : 'Suggestion:')); results.push.apply( results, this.suggestions.map((note) => ` ➤ ${note}`)); } results.push(''); // push a final newline on return results.join('\n'); } static _sourceLine(ln) { return `${chalk.blue(`${ln.line}:`)} ${chalk.white(ln.src)}`; } static _extractContext(sourceContents, line, contextSize) { const lines = sourceContents.toString().split('\n'); const minLine = Math.max(line - contextSize - 1, 0); const maxLine = Math.min(line + contextSize, lines.length); if (line > lines.length) { return []; } else { return lines.slice(minLine, maxLine).map((src, i) => ({ line: i + minLine + 1, src, })); } } static _formatContext(sourceContents, line, col, contextSize) { return this._extractContext(sourceContents, line, contextSize) .map((srcLine) => { let formatted = this._sourceLine(srcLine); if (srcLine.line === line) { const prefix = `${line}: `; formatted += `\n${' '.repeat(prefix.length + col - 1)}${chalk.green('^')}`; } return formatted; }); } } module.exports = NiceError; ================================================ FILE: cli/src/utils/parse_yes_no_option.js ================================================ 'use strict'; module.exports = (value, option_name) => { if (value !== undefined && value !== null) { const lower = value.toLowerCase ? value.toLowerCase() : value; if (lower === true || lower === 'true' || lower === 'yes') { return true; } else if (lower === false || lower === 'false' || lower === 'no') { return false; } throw new Error(`Unexpected value "${option_name}=${value}", should be yes or no.`); } }; ================================================ FILE: cli/src/utils/proc-promise.js ================================================ 'use strict'; const Promise = require('bluebird'); const childProcess = require('child_process'); function procPromise() { // Takes the same arguments as child_process.spawn const args = Array.prototype.slice.call(arguments); return new Promise((resolve, reject) => { const proc = childProcess.spawn.apply(childProcess, args); proc.stderr.setEncoding('utf8'); proc.stdout.setEncoding('utf8'); proc.on('exit', (code) => { if (code === 0) { resolve(proc); } else { const err = new Error(proc.stderr.read()); err.exitCode = code; reject(err); } }); }); } module.exports = procPromise; ================================================ FILE: cli/src/utils/rethrow.js ================================================ 'use strict'; // Returns a new Error with the given message. Combines the stack // traces with the old error, and removes itself from the stack trace. module.exports = (e, newMessage) => { let e2; if (typeof newMessage === 'string') { e2 = new Error(newMessage); e2.stack = e2.stack.split('\n'); e2.stack.splice(1, 1); // Remove rethrow from stack trace } else { e2 = newMessage; } e2.stack += '\n\n ==== Original stack trace ====\n\n'; e2.stack += e.stack; return e2; }; ================================================ FILE: cli/src/utils/rm_sync_recursive.js ================================================ 'use strict'; const fs = require('fs'); const path = require('path'); const rmdirSyncRecursive = (dir) => { try { fs.readdirSync(dir).forEach((item) => { const full_path = path.join(dir, item); if (fs.statSync(full_path).isDirectory()) { rmdirSyncRecursive(full_path); } else { fs.unlinkSync(full_path); } }); fs.rmdirSync(dir); } catch (err) { /* Do nothing */ } }; module.exports = rmdirSyncRecursive; ================================================ FILE: cli/src/utils/start_rdb_server.js ================================================ 'use strict'; const each_line_in_pipe = require('./each_line_in_pipe'); const horizon_server = require('@horizon/server'); const execSync = require('child_process').execSync; const spawn = require('child_process').spawn; const hasbinSync = require('hasbin').sync; const defaultDatadir = 'rethinkdb_data'; const infoLevelLog = (msg) => /^Running/.test(msg) || /^Listening/.test(msg); const r = horizon_server.r; const logger = horizon_server.logger; const version_check = horizon_server.utils.rethinkdb_version_check; class RethinkdbServer { constructor(options) { const quiet = Boolean(options.quiet); const bind = options.bind || [ '127.0.0.1' ]; const dataDir = options.dataDir || defaultDatadir; const driverPort = options.rdbPort; const httpPort = options.rdbHttpPort; const cacheSize = options.cacheSize || 200; // Check if `rethinkdb` in PATH if (!hasbinSync('rethinkdb')) { throw new Error('`rethinkdb` not found in $PATH, please install RethinkDB.'); } // Check if RethinkDB is sufficient version for Horizon version_check(execSync('rethinkdb --version', { timeout: 5000 }).toString()); const args = [ '--http-port', String(httpPort || 0), '--cluster-port', '0', '--driver-port', String(driverPort || 0), '--cache-size', String(cacheSize), '--directory', dataDir, '--no-update-check' ]; bind.forEach((host) => args.push('--bind', host)); this.proc = spawn('rethinkdb', args); this.ready_promise = new Promise((resolve, reject) => { this.proc.once('error', reject); this.proc.once('exit', (exit_code) => { if (exit_code !== 0) { reject(new Error(`RethinkDB process terminated with error code ${exit_code}.`)); } }); process.on('exit', () => { if (this.proc.exitCode === null) { logger.error('Unclean shutdown - killing RethinkDB child process'); this.proc.kill('SIGKILL'); } }); const maybe_resolve = () => { // Once we have both ports determined, callback with all settings. if (this.http_port !== undefined && this.driver_port !== undefined) { resolve(this); } }; each_line_in_pipe(this.proc.stdout, (line) => { if (!quiet) { if (infoLevelLog(line)) { logger.info('RethinkDB', line); } else { logger.debug('RethinkDB stdout:', line); } } if (this.driver_port === undefined) { const matches = line.match( /^Listening for client driver connections on port (\d+)$/); if (matches !== null && matches.length === 2) { this.driver_port = parseInt(matches[1]); maybe_resolve(); } } if (this.http_port === undefined) { const matches = line.match( /^Listening for administrative HTTP connections on port (\d+)$/); if (matches !== null && matches.length === 2) { this.http_port = parseInt(matches[1]); maybe_resolve(); } } }); each_line_in_pipe(this.proc.stderr, (line) => logger.error(`rethinkdb stderr: ${line}`)); }); } ready() { return this.ready_promise; } // This is only used by tests - cli commands use a more generic method as // the database may be launched elsewhere. connect() { return r.connect({ host: 'localhost', port: this.driver_port }); } close() { return new Promise((resolve) => { if (this.proc.exitCode !== null) { resolve(); } else { this.proc.kill('SIGTERM'); this.proc.once('exit', () => { resolve(); }); setTimeout(() => { this.proc.kill('SIGKILL'); resolve(); }, 20000).unref(); } }); } } // start_rdb_server // Options: // quiet: boolean, suppresses rethinkdb log messages // bind: array of ip addresses to bind to, or 'all' // dataDir: name of rethinkdb data directory. Defaults to `rethinkdb_data` // driverPort: port number for rethinkdb driver connections. Auto-assigned by default. // httpPort: port number for webui. Auto-assigned by default. // cacheSize: cacheSize to give to rethinkdb in MB. Default 200. module.exports = (options) => new RethinkdbServer(options || { }).ready(); module.exports.r = r; ================================================ FILE: cli/src/version.js ================================================ 'use strict'; const package_json = require('../package.json'); const run = (args) => Promise.resolve().then(() => { if (args && args.length) { throw new Error('create-cert takes no arguments'); } console.info(package_json.version); }); module.exports = { run, description: 'Print the version number of horizon', }; ================================================ FILE: cli/test/config.js ================================================ 'use strict'; const serve = require('../src/serve'); const processConfig = serve.processConfig; const assert = require('assert'); const mockFs = require('mock-fs'); const make_flags = (flags) => Object.assign({}, serve.parseArguments([]), flags); const write_config = (config) => { let data = ''; const recursive_add = (obj, path) => { const value_keys = [ ]; const object_keys = [ ]; for (const key in obj) { const val = obj[key]; if (typeof val === 'object' && val !== null && !Array.isArray(val)) { object_keys.push(key); } else { value_keys.push(key); } } if (value_keys.length > 0) { if (path) { data += `[${path}]\n`; } value_keys.forEach((key) => { data += `${key} = ${JSON.stringify(obj[key])}\n`; }); } object_keys.forEach((key) => { recursive_add(obj[key], `${path}${path ? '.' : ''}${key}`); }); }; recursive_add(config, ''); mockFs({ '.hz': { 'config.toml': data } }); }; describe('Config', () => { let original_env; before('Save env', () => { original_env = Object.assign({}, process.env); }); beforeEach('Create empty config file', () => { write_config({ }); }); after('Restore fs', () => { mockFs.restore(); }); afterEach('Restore env', () => { process.env = Object.assign({}, original_env); }); it('precedence', () => { // Test a yes/no flag (in this case `secure`) // Make a list of all possible states to test, each item contains // the state of the config file, the env, and the flags const states = [ ]; const values = [ 'yes', 'no', 'false', 'true', false, true, null ]; for (let i = 0; i < Math.pow(values.length, 3); ++i) { states.push([ values[i % 3], values[Math.floor(i / 3) % 3], values[Math.floor(i / 9) % 3] ]); } states.forEach((state) => { const parsed = { }; let expected = true; // default value if (state[0] !== null) { write_config({ secure: state[0] }); expected = state[0]; } else { write_config({ }); } if (state[1] !== null) { process.env.HZ_INSECURE = `${state[1]}`; expected = state[1]; } else { delete process.env.HZ_INSECURE; } if (state[2] !== null) { parsed.secure = state[2]; expected = state[2]; } expected = (expected === 'yes' || expected === 'true') || expected; expected = (expected === 'no' || expected === 'false') ? false : expected; assert.strictEqual(processConfig(make_flags(parsed)).secure, expected); }); }); // An unrecognized parameter in a config file should cause an error it('unknown field in file', () => { write_config({ fake_field: 'foo' }); assert.throws(() => processConfig(make_flags({ })), /Unknown config parameter: "fake_field"./); }); // An unrecognized environment variable that matches the pattern should be ignored it('unknown field in env', () => { process.env.HZ_FAKE_FIELD = 'foo'; const config = processConfig(make_flags({ })); assert.strictEqual(config.fake_field, undefined); }); // The port parameter should always be stored as a number describe('connect', () => { it('valid in file', () => { write_config({ connect: 'localhost:123' }); const config = processConfig(make_flags({ })); assert.strictEqual(config.rdb_port, 123); }); it('valid in env', () => { process.env.HZ_CONNECT = 'localhost:456'; const config = processConfig(make_flags({ })); assert.strictEqual(config.rdb_port, 456); }); // Make sure an error is thrown if the format is wrong it('invalid format in file', () => { write_config({ connect: 'local:host:111' }); assert.throws(() => processConfig(make_flags({ })), /Expected --connect HOST:PORT, but found "local:host:111"./); }); it('invalid format in env', () => { process.env.HZ_CONNECT = 'local:host:111'; assert.throws(() => processConfig(make_flags({ })), /Expected --connect HOST:PORT, but found "local:host:111"./); }); it('invalid format in flags', () => { assert.throws(() => processConfig(make_flags({ connect: 'local:host:111' })), /Expected --connect HOST:PORT, but found "local:host:111"./); }); // Make sure an error is thrown if the port cannot be parsed it('invalid port in file', () => { write_config({ connect: 'localhost:cat' }); assert.throws(() => processConfig(make_flags({ })), /Invalid port: "cat"./); }); it('invalid port in env', () => { process.env.HZ_CONNECT = 'localhost:dog'; assert.throws(() => processConfig(make_flags({ })), /Invalid port: "dog"./); }); it('invalid port in flags', () => { assert.throws(() => processConfig(make_flags({ connect: 'localhost:otter' })), /Invalid port: "otter"./); }); it('with start_rethinkdb in file', () => { write_config({ connect: 'localhost:123', start_rethinkdb: true }); assert.throws(() => processConfig(make_flags({ })), /Cannot provide both --start-rethinkdb and --connect./); }); it('with start_rethinkdb in env', () => { process.env.HZ_CONNECT = 'localhost:123'; process.env.HZ_START_RETHINKDB = 'true'; assert.throws(() => processConfig(make_flags({ })), /Cannot provide both --start-rethinkdb and --connect./); }); it('with start_rethinkdb in flags', () => { assert.throws(() => processConfig(make_flags({ connect: 'localhost:123', start_rethinkdb: true })), /Cannot provide both --start-rethinkdb and --connect./); }); it('with enabling and disabling start_rethinkdb', () => { write_config({ start_rethinkdb: true }); process.env.HZ_CONNECT = 'example:123'; const config = processConfig(make_flags({ start_rethinkdb: false })); assert.strictEqual(config.start_rethinkdb, false); assert.strictEqual(config.rdb_host, 'example'); assert.strictEqual(config.rdb_port, 123); }); it('with start_rethinkdb across configs', () => { let config; write_config({ connect: 'example:123' }); config = processConfig( make_flags({ start_rethinkdb: true })); assert.strictEqual(config.start_rethinkdb, true); write_config({ start_rethinkdb: true }); config = processConfig( make_flags({ connect: 'example:123' })); assert.strictEqual(config.start_rethinkdb, false); assert.strictEqual(config.rdb_host, 'example'); assert.strictEqual(config.rdb_port, 123); }); it('with dev mode and start_rethinkdb across configs', () => { let config; write_config({ connect: 'example:123' }); config = processConfig(make_flags({ dev: true })); assert.strictEqual(config.start_rethinkdb, false); assert.strictEqual(config.rdb_host, 'example'); assert.strictEqual(config.rdb_port, 123); write_config({ connect: 'example:123' }); config = processConfig( make_flags({ start_rethinkdb: false, dev: true })); assert.strictEqual(config.start_rethinkdb, false); assert.strictEqual(config.rdb_host, 'example'); assert.strictEqual(config.rdb_port, 123); write_config({ connect: 'example:123' }); config = processConfig( make_flags({ start_rethinkdb: true, dev: true })); assert.strictEqual(config.start_rethinkdb, true); write_config({ start_rethinkdb: true }); config = processConfig(make_flags({ dev: true })); assert.strictEqual(config.start_rethinkdb, true); write_config({ start_rethinkdb: true }); config = processConfig( make_flags({ connect: 'example:123', dev: true })); assert.strictEqual(config.start_rethinkdb, false); assert.strictEqual(config.rdb_host, 'example'); assert.strictEqual(config.rdb_port, 123); }); }); // The bind parameter must be stored as an array of hostnames describe('bind', () => { it('in file', () => { write_config({ bind: [ 'foo', 'bar' ] }); const config = processConfig(make_flags({ })); assert.deepStrictEqual(config.bind, [ 'foo', 'bar' ]); }); it('in env', () => { process.env.HZ_BIND = 'foo,bar'; const config = processConfig(make_flags({ })); assert.deepStrictEqual(config.bind, [ 'foo', 'bar' ]); }); it('in flags', () => { const config = processConfig(make_flags({ bind: [ 'foo', 'bar' ] })); assert.deepStrictEqual(config.bind, [ 'foo', 'bar' ]); }); }); // Auth parameters are handled slightly differently to other parameters // They add to an object, rather than having an explicit option name // for each auth provider. it('auth', () => { // provider 'foo' and 'far' through config file write_config({ auth: { foo: { id: 'foo_id', secret: 'foo_secret' }, far: { id: 'far_id', secret: 'far_secret' }, } }); // provider 'bar' and 'baz' through env process.env.HZ_AUTH_BAR_ID = 'bar_id'; process.env.HZ_AUTH_BAR_SECRET = 'bar_secret'; process.env.HZ_AUTH_BAZ_ID = 'baz_id'; process.env.HZ_AUTH_BAZ_SECRET = 'baz_secret'; // overwrite 'far' through env process.env.HZ_AUTH_FAR_ID = 'far_id_new'; process.env.HZ_AUTH_FAR_SECRET = 'far_secret_new'; // provider 'bamf' through command-line // overwrite 'baz' through command-line const config = processConfig(make_flags({ auth: [ 'bamf,bamf_id,bamf_secret', 'baz,baz_id_new,baz_secret_new' ] })); assert.deepStrictEqual(config.auth, { foo: { id: 'foo_id', secret: 'foo_secret' }, far: { id: 'far_id_new', secret: 'far_secret_new' }, bar: { id: 'bar_id', secret: 'bar_secret' }, baz: { id: 'baz_id_new', secret: 'baz_secret_new' }, bamf: { id: 'bamf_id', secret: 'bamf_secret' }, }); }); }); ================================================ FILE: cli/test/init.spec.js ================================================ /* global beforeEach, describe, process, afterEach, it, require */ 'use strict'; const init = require('../src/init'); const assert = require('chai').assert; const fs = require('fs'); const mockFs = require('mock-fs'); const path = require('path'); const sinon = require('sinon'); const toml = require('toml'); const original_dir = path.resolve(process.cwd()); const hz_dir = `${original_dir}/.hz`; const assertNameExists = (baseDir, fileName) => { const files = fs.readdirSync(baseDir); assert.include(files, fileName); }; const assertNameDoesntExist = (baseDir, fileName) => { const files = fs.readdirSync(baseDir); assert.notInclude(files, fileName); }; const assertDirExists = (baseDir, dirName) => { assertNameExists(baseDir, dirName); assert.isTrue(fs.statSync(`${baseDir}/${dirName}`).isDirectory()); }; const assertDirDoesntExist = (baseDir, dirName) => { assertNameDoesntExist(baseDir, dirName); }; const assertFileExists = (baseDir, fileName) => { assertNameExists(baseDir, fileName); assert.isTrue(fs.statSync(`${baseDir}/${fileName}`).isFile()); }; const assertFileDoesntExist = (baseDir, fileName) => { assertNameDoesntExist(baseDir, fileName); }; const getFileString = (filepath) => fs.readFileSync(filepath, { encoding: 'utf8' }); const readToml = (filepath) => { const tomlData = getFileString(filepath); return toml.parse(tomlData); }; const assertValidConfig = (filepath) => { const configObject = readToml(filepath); // Need an uncommented project name assert.property(configObject, 'project_name'); }; const assertValidSecrets = (filepath) => { const secretsObject = readToml(filepath); // Need an uncommented token_secret assert.property(secretsObject, 'token_secret'); }; describe('hz init', () => { beforeEach('redirect console out', () => { sinon.stub(console, 'error'); sinon.stub(console, 'info'); }); afterEach('restore console out', () => { console.error.restore(); console.info.restore(); }); afterEach('restore cwd', () => process.chdir(original_dir)); afterEach('clear mockfs', () => mockFs.restore()); describe('when passed a project name', () => { const testDirName = 'test-app'; const projectDir = `${original_dir}/${testDirName}`; const args = [ testDirName ]; it("creates the project dir if it doesn't exist", () => { mockFs(); return init.run(args).then(() => assertDirExists(original_dir, testDirName) ); }); it('moves into the project dir', () => { mockFs({ [testDirName]: { } }); return init.run(args).then(() => assert.equal(process.cwd(), `${original_dir}/${testDirName}`) ); }); describe('when the project dir is empty', () => { beforeEach('initialize mockfs', () => { mockFs({ [projectDir]: {} }); }); it('creates the src dir', () => init.run(args).then(() => assertDirExists(projectDir, 'src'))); it('creates the dist dir', () => init.run(args).then(() => assertDirExists(projectDir, 'dist'))); it('creates an example dist/index.html', () => init.run(args).then(() => assertFileExists(`${projectDir}/dist`, 'index.html'))); it('creates the .hz dir', () => init.run(args).then(() => assertDirExists(projectDir, '.hz'))); it('creates the .gitignore file', () => init.run(args).then(() => assertFileExists(`${projectDir}`, '.gitignore'))); it('creates the .hz/config.toml file', () => init.run(args).then(() => assertFileExists(`${projectDir}/.hz`, 'config.toml'))); it('creates the .hz/secrets.toml file', () => init.run(args).then(() => assertFileExists(`${projectDir}/.hz`, 'secrets.toml'))); it('creates the .hz/schema.toml file', () => init.run(args).then(() => assertFileExists(`${projectDir}/.hz`, 'schema.toml'))); }); describe('when the project dir is not empty', () => { beforeEach('initialize mockfs', () => { mockFs({ [projectDir]: { lib: { 'someFile.html': 'Something' }, }, }); }); it("doesn't create the src dir", () => init.run(args).then(() => assertDirDoesntExist(projectDir, 'src'))); it("doesn't create the dist dir", () => init.run(args).then(() => assertDirDoesntExist(projectDir, 'dist'))); it("doesn't create an example dist/index.html", () => { fs.mkdirSync(`${projectDir}/dist`); return init.run(args).then(() => assertFileDoesntExist(`${projectDir}/dist`, 'index.html') ); }); it("still creates the .hz dir if it doesn't exist", () => init.run(args).then(() => assertDirExists(projectDir, '.hz'))); it("doesn't create the .hz dir if it already exists", () => { fs.mkdirSync(`${projectDir}/.hz`); const beforeMtime = fs.statSync(`${projectDir}/.hz`).birthtime.getTime(); return init.run(args).then(() => { const afterMtime = fs.statSync(`${projectDir}/.hz`).birthtime.getTime(); assert.equal(beforeMtime, afterMtime, '.hz was modified'); }); }); it("creates a valid config.toml if it doesn't exist", () => { fs.mkdirSync(`${projectDir}/.hz`); return init.run(args).then(() => { assertFileExists(`${projectDir}/.hz`, 'config.toml'); assertValidConfig(`${projectDir}/.hz/config.toml`); }); }); it("creates a valid secrets.toml if it doesn't exist", () => { fs.mkdirSync(`${projectDir}/.hz`); return init.run(args).then(() => { assertFileExists(`${projectDir}/.hz`, 'secrets.toml'); assertValidSecrets(`${projectDir}/.hz/secrets.toml`); }); }); it("creates a valid schema.toml if it doesn't exist", () => { fs.mkdirSync(`${projectDir}/.hz`); return init.run(args).then(() => { assertFileExists(`${projectDir}/.hz`, 'schema.toml'); // TODO: assertValidSchema(`${projectDir}/.hz/schema.toml`); }); }); it("doesn't touch the config.toml if it already exists", () => { fs.mkdirSync(`${projectDir}/.hz`); const filename = `${projectDir}/.hz/config.toml`; fs.appendFileSync(filename, '#Hoo\n'); const beforeMtime = fs.statSync(filename).mtime.getTime(); return init.run(args).then(() => { const afterMtime = fs.statSync(filename).mtime.getTime(); assert.equal(beforeMtime, afterMtime); const afterContents = getFileString(filename); assert.equal('#Hoo\n', afterContents); }); }); it("doesn't touch the secrets.toml if it already exists", () => { fs.mkdirSync(`${projectDir}/.hz`); const filename = `${projectDir}/.hz/secrets.toml`; fs.appendFileSync(filename, '#Hoo\n'); const beforeMtime = fs.statSync(filename).mtime.getTime(); return init.run(args).then(() => { const afterMtime = fs.statSync(filename).mtime.getTime(); assert.equal(beforeMtime, afterMtime); const afterContents = getFileString(filename); assert.equal('#Hoo\n', afterContents); }); }); it("doesn't touch the schema.toml if it already exists", () => { fs.mkdirSync(`${projectDir}/.hz`); const filename = `${projectDir}/.hz/schema.toml`; fs.appendFileSync(filename, '#Hoo\n'); const beforeMtime = fs.statSync(filename).mtime.getTime(); return init.run(args).then(() => { const afterMtime = fs.statSync(filename).mtime.getTime(); assert.equal(beforeMtime, afterMtime); const afterContents = getFileString(filename); assert.equal('#Hoo\n', afterContents); }); }); }); }); describe('when not passed a project name', () => { const args = [ ]; it('stays in the current directory', () => { mockFs(); return init.run(args).then(() => { const afterCwd = process.cwd(); assert.equal(original_dir, afterCwd, 'init changed directories'); }); }); describe('in an empty directory', () => { beforeEach('initialize mockfs', () => mockFs({})); it('creates the src dir', () => init.run(args).then(() => assertDirExists(original_dir, 'src'))); it('creates the dist dir', () => init.run(args).then(() => assertDirExists(original_dir, 'dist'))); it('creates an example dist/index.html', () => init.run(args).then(() => assertFileExists(`${original_dir}/dist`, 'index.html'))); it('creates the .hz dir', () => init.run(args).then(() => assertDirExists(original_dir, '.hz'))); it('creates the .hz/config.toml file', () => init.run(args).then(() => { assertFileExists(hz_dir, 'config.toml'); assertValidConfig(`${hz_dir}/config.toml`); })); }); describe('in a directory with files in it', () => { beforeEach('initialize mocks', () => { mockFs({ lib: { 'some_file.txt': 'Some file content' }, }); }); it("doesn't create the src dir", () => init.run(args).then(() => assertDirDoesntExist(original_dir, 'src'))); it("doesn't create the dist dir", () => init.run(args).then(() => assertDirDoesntExist(original_dir, 'dist'))); it("doesn't create an example dist/index.html", () => { fs.mkdirSync(`${original_dir}/dist`); return init.run(args).then(() => assertFileDoesntExist(`${original_dir}/dist`, 'index.html') ); }); it("creates the .hz dir if it doesn't exist", () => init.run(args).then(() => assertDirExists(original_dir, '.hz'))); it("doesn't create the .hz dir if it exists", () => { fs.mkdirSync(hz_dir); const beforeTime = fs.statSync(hz_dir).birthtime.getTime(); return init.run(args).then(() => { assertDirExists(original_dir, '.hz'); const afterTime = fs.statSync(hz_dir).birthtime.getTime(); assert.equal(beforeTime, afterTime, '.hz birthtime changed'); }); }); it("creates the config.toml if it doesn't exist", () => init.run(args).then(() => { assertFileExists(hz_dir, 'config.toml'); assertValidConfig(`${hz_dir}/config.toml`); }) ); it("doesn't touch the config.toml if it already exists", () => { fs.mkdirSync(hz_dir); const filename = `${hz_dir}/config.toml`; fs.appendFileSync(filename, '#Hoo\n'); const beforeMtime = fs.statSync(filename).mtime.getTime(); return init.run(args).then(() => { const afterMtime = fs.statSync(filename).mtime.getTime(); assert.equal(beforeMtime, afterMtime); const afterContents = getFileString(filename); assert.equal('#Hoo\n', afterContents); }); }); }); }); }); ================================================ FILE: cli/test/schema.spec.js ================================================ 'use strict'; const processApplyConfig = require('../src/schema').processApplyConfig; const runApplyCommand = require('../src/schema').runApplyCommand; const runSaveCommand = require('../src/schema').runSaveCommand; const parse_schema = require('../src/schema').parse_schema; const start_rdb_server = require('../src/utils/start_rdb_server'); const rm_sync_recursive = require('../src/utils/rm_sync_recursive'); const assert = require('assert'); const fs = require('fs'); const r = start_rdb_server.r; const mockFs = require('mock-fs'); const tmpdir = require('os').tmpdir; const project_name = 'schema_test'; const v1_schema = ` [collections.test_messages] indexes = ["datetime"] [groups.admin] [groups.admin.rules.carte_blanche] template = "any()" `; const v2_schema = `# This is a TOML document [collections.users] [collections.test_messages] [[collections.test_messages.indexes]] fields = [["a","b"],["c"]] [[collections.test_messages.indexes]] fields = [["datetime"]] [groups.admin] [groups.admin.rules.carte_blanche] template = "any()" `; const brokenTestSchema = ` [collectiosfklajsfns.test_messages] indexes = ["datetime"] [groups.adminasklfjasf] [groups.admin.rules.carte_blanche] template = "any()a;lkdfjlakjf;ladkfjal;kfj" `; const fs_with_schema = (schema) => { mockFs({ '.hz': { 'schema.toml': schema } }); }; describe('hz schema', () => { const rdb_data_dir = `${tmpdir()}/horizon-test-${process.pid}`; let rdb_conn, rdb_server; before('start rethinkdb', () => start_rdb_server({ quiet: true, dataDir: rdb_data_dir, }).then((server) => { rdb_server = server; }) ); after('stop rethinkdb', () => rdb_server && rdb_server.close()); after('delete rethinkdb data directory', () => rm_sync_recursive(rdb_data_dir)); before('connect to rethinkdb', () => rdb_server.connect().then((conn) => { rdb_conn = conn; }) ); beforeEach('initialize mockfs', () => fs_with_schema(v2_schema)); afterEach('restore fs', () => mockFs.restore()); describe('save', () => { before('initialize database', () => { fs_with_schema(v2_schema); return runApplyCommand(processApplyConfig({ start_rethinkdb: false, schema_file: '.hz/schema.toml', project_name, connect: `localhost:${rdb_server.driver_port}`, })); }); after('clear database', () => r.branch( r.dbList().contains(project_name), r.dbDrop(project_name), null ).run(rdb_conn) ); it('renames previous schema.toml if it already exists', () => runSaveCommand({ start_rethinkdb: false, rdb_host: 'localhost', rdb_port: rdb_server.driver_port, out_file: '.hz/schema.toml', project_name, }).then(() => assert.equal(fs.readdirSync('.hz').length, 2, 'backup schema file not created') ) ); it('saves schema to schema.toml from rdb', () => runSaveCommand({ start_rethinkdb: false, rdb_host: 'localhost', rdb_port: rdb_server.driver_port, out_file: 'out.toml', project_name, }).then(() => assert.strictEqual(fs.readFileSync('out.toml', 'utf8'), v2_schema) ) ); }); describe('apply', () => { afterEach('clear database', () => r.branch( r.dbList().contains(project_name), r.dbDrop(project_name), null ).run(rdb_conn) ); it('applies v1.x schema to rdb from schema.toml', () => { fs_with_schema(v1_schema); const config = processApplyConfig({ connect: `localhost:${rdb_server.driver_port}`, schema_file: '.hz/schema.toml', start_rethinkdb: false, update: true, force: true, secure: false, permissions: false, project_name, }); // Apply settings into RethinkDB return runApplyCommand(config).then(() => // Check that the project database exists r.dbList().contains(project_name).run(rdb_conn) ).then((res) => assert(res, `${project_name} database is missing.`) ).then(() => r.db(project_name).table('test_messages').indexList().run(rdb_conn) ).then((indexes) => { // Check that the expected indexes exist on the expected table assert(indexes.indexOf('hz_[["datetime"]]') !== -1, '"datetime" index is missing'); }); }); it('applies v2.x schema to rdb from schema.toml', () => { const config = processApplyConfig({ connect: `localhost:${rdb_server.driver_port}`, schema_file: '.hz/schema.toml', start_rethinkdb: false, update: true, force: true, secure: false, permissions: false, project_name, }); // Apply settings into RethinkDB return runApplyCommand(config).then(() => // Check that the project database exists r.dbList().contains(project_name).run(rdb_conn) ).then((res) => assert(res, `${project_name} database is missing.`) ).then(() => r.db(project_name).table('test_messages').indexList().run(rdb_conn) ).then((indexes) => { // Check that the expected indexes exist on the expected table assert(indexes.indexOf('hz_[["datetime"]]') !== -1, '"datetime" index is missing'); assert(indexes.indexOf('hz_[["a","b"],["c"]]') !== -1, '[["a","b"],["c"]] index is missing'); }); }); }); describe('given a schema.toml file', () => { it('can parse a valid v1.x schema.toml file', () => { parse_schema(v1_schema); }); it('can parse a valid v2.x schema.toml file', () => { parse_schema(v2_schema); }); it('fails parsing invalid schema.toml file', () => { assert.throws(() => { parse_schema(brokenTestSchema); }, /"collectiosfklajsfns" is not allowed/); }); it('can read a vaild schema.toml file', () => processApplyConfig({ start_rethinkdb: true, schema_file: '.hz/schema.toml', update: true, force: true, }) ); }); }); ================================================ FILE: cli/test/serve.spec.js ================================================ 'use strict'; const serve = require('../src/serve'); const schema = require('../src/schema'); const start_rdb_server = require('../src/utils/start_rdb_server'); const rm_sync_recursive = require('../src/utils/rm_sync_recursive'); const tmpdir = require('os').tmpdir; const assert = require('chai').assert; const mockFs = require('mock-fs'); const project_name = 'test_app'; const serve_args = [ '--secure=false', '--port=0', '--auto-create-collection', `--project-name=${project_name}`, `--token-secret=test-token` ]; const make_args = (args) => serve_args.concat(args); const valid_project_data = { '.hz': { 'config.toml': 'project_name = "projectName"\n', }, }; describe('hz serve', () => { const rdb_data_dir = `${tmpdir()}/horizon-test-${process.pid}`; let rdb_server; before('start rethinkdb', () => start_rdb_server({ quiet: true, dataDir: rdb_data_dir, }).then((server) => { rdb_server = server; serve_args.push(`--connect=localhost:${rdb_server.driver_port}`); }) ); // Run schema apply with a blank schema before('initialize rethinkdb', () => { mockFs({ 'schema.toml': '' }); return schema.run([ 'apply', 'schema.toml', `--connect=localhost:${rdb_server.driver_port}`, `--project-name=${project_name}` ]) .then(() => mockFs.restore()); }); after('stop rethinkdb', () => rdb_server && rdb_server.close()); after('delete rethinkdb data directory', () => rm_sync_recursive(rdb_data_dir)); afterEach('restore mockfs', () => mockFs.restore()); describe('with a project path', () => { beforeEach('initialize mockfs', () => mockFs({ [project_name]: valid_project_data })); it('changes to the project directory', () => { const before_dir = process.cwd(); return serve.run(make_args([ project_name ]), Promise.resolve()).then(() => assert.strictEqual(`${before_dir}/${project_name}`, process.cwd(), 'directory should have changed') ); }); it('fails if the .hz dir does not exist', () => { mockFs({ [project_name]: {} }); return serve.run(make_args([ project_name ]), Promise.resolve()).then(() => assert(false, 'should have failed because the .hz directory is missing') ).catch((err) => assert.throws(() => { throw err; }, /doesn't contain an .hz directory/) ); }); it('continues if .hz dir does exist', () => serve.run(make_args([ project_name ]), Promise.resolve()) ); }); describe('without a project path', () => { beforeEach('initialize mockfs', () => mockFs(valid_project_data)); it('does not change directories', () => { const before_dir = process.cwd(); return serve.run(make_args([ '.' ]), Promise.resolve()).then(() => assert.strictEqual(before_dir, process.cwd(), 'directory should not have changed') ); }); it('fails if the .hz dir does not exist', () => { mockFs({ }); return serve.run(make_args([ '.' ]), Promise.resolve()).then(() => assert(false, 'should have failed because the .hz directory is missing') ).catch((err) => assert.throws(() => { throw err; }, /doesn't contain an .hz directory/) ); }); it('continues if .hz dir does exist', () => serve.run(make_args([ '.' ]), Promise.resolve()) ); }); }); ================================================ FILE: cli/test/unit/check-project-name.js ================================================ /* global describe, it */ 'use strict'; const checkProjectName = require('../../src/utils/check-project-name'); const assert = require('chai').assert; describe('checkProjectName', () => { describe('when passed null for a directory', () => { const prospectiveName = null; const dirList = []; it("doesn't change directory", (done) => { const goodCwd = '/foo/bar/Ba_z'; const res = checkProjectName(prospectiveName, goodCwd, dirList); assert.propertyVal(res, 'chdirTo', false); assert.propertyVal(res, 'dirName', 'Ba_z'); assert.propertyVal(res, 'projectName', 'Ba_z'); assert.propertyVal(res, 'createDir', false); done(); }); it('throws if the cwd has invalid chars', (done) => { const badCwd = '/foo/bar/b*a&z'; assert.throws(() => { checkProjectName(prospectiveName, badCwd, dirList); }, '*&'); done(); }); it('sets projectName to dehyphenated cwd if fixable', (done) => { const fixableCwd = '/foo/bar/ba-z'; const res = checkProjectName( prospectiveName, fixableCwd, dirList); assert.propertyVal(res, 'projectName', 'ba_z'); assert.propertyVal(res, 'dirName', 'ba-z'); assert.propertyVal(res, 'chdirTo', false); assert.propertyVal(res, 'createDir', false); done(); }); }); describe('when passed "." as a directory', () => { const prospectiveName = '.'; const dirList = []; it("doesn't change directory", (done) => { const goodCwd = '/foo/bar/Ba_z'; const res = checkProjectName(prospectiveName, goodCwd, dirList); assert.propertyVal(res, 'chdirTo', false); assert.propertyVal(res, 'dirName', 'Ba_z'); assert.propertyVal(res, 'projectName', 'Ba_z'); assert.propertyVal(res, 'createDir', false); done(); }); it('throws if the cwd has invalid chars', (done) => { const badCwd = '/foo/bar/b*a&z'; assert.throws(() => { checkProjectName(prospectiveName, badCwd, dirList); }, '*&'); done(); }); it('sets projectName to dehyphenated cwd if fixable', (done) => { const fixableCwd = '/foo/bar/ba-z'; const res = checkProjectName(prospectiveName, fixableCwd, dirList); assert.propertyVal(res, 'projectName', 'ba_z'); assert.propertyVal(res, 'dirName', 'ba-z'); assert.propertyVal(res, 'chdirTo', false); assert.propertyVal(res, 'createDir', false); done(); }); }); describe('when passed a non-existing directory', () => { const dirList = [ 'a', 'b', 'c' ]; const cwd = '/foo/bar'; it('creates the directory when name is valid', (done) => { const results = checkProjectName('Ba_z9', cwd, dirList); assert.propertyVal(results, 'projectName', 'Ba_z9'); assert.propertyVal(results, 'createDir', true); assert.propertyVal(results, 'chdirTo', '/foo/bar/Ba_z9'); assert.propertyVal(results, 'dirName', 'Ba_z9'); done(); }); it('creates the directory when name is fixable', (done) => { const results = checkProjectName('Ba-z9', cwd, dirList); assert.propertyVal(results, 'projectName', 'Ba_z9'); assert.propertyVal(results, 'createDir', true); assert.propertyVal(results, 'chdirTo', '/foo/bar/Ba-z9'); assert.propertyVal(results, 'dirName', 'Ba-z9'); done(); }); it('throws an error if the name is not fixable', (done) => { assert.throws(() => { checkProjectName('Some*Bad+Name', cwd, dirList); }, '*+'); done(); }); }); describe('when passed an existing directory', () => { const dirList = [ 'a', 'Ba-z', 'B^a%z', 'Ba_z9' ]; const cwd = '/foo/bar'; it('errors if given an invalid projectName', (done) => { assert.throws(() => { checkProjectName('B^%z', cwd, dirList); }, '^%'); done(); }); it('changes directory if the name is good', (done) => { const res = checkProjectName('Ba_z9', cwd, dirList); assert.propertyVal(res, 'dirName', 'Ba_z9'); assert.propertyVal(res, 'projectName', 'Ba_z9'); assert.propertyVal(res, 'chdirTo', '/foo/bar/Ba_z9'); assert.propertyVal(res, 'createDir', false); done(); }); it('changes directory if the name is fixable', (done) => { const res = checkProjectName('Ba-z', cwd, dirList); assert.propertyVal(res, 'dirName', 'Ba-z'); assert.propertyVal(res, 'projectName', 'Ba_z'); assert.propertyVal(res, 'chdirTo', '/foo/bar/Ba-z'); assert.propertyVal(res, 'createDir', false); done(); }); }); }); ================================================ FILE: cli/test/unit/nice_error.js ================================================ 'use strict'; const stripAnsi = require('strip-ansi'); const assert = require('chai').assert; const NiceError = require('../../src/utils/nice_error'); const fakeFile = `\ some = fake, syntax next := some(1, 2, 3) def foo(bar) { -- what language is this? } `; describe('NiceError', () => { describe('._sourceLine', () => { it('should have blue line number and white source text', (done) => { const inputs = [ { src: 'foo bar', line: 2 }, { src: 'baz wux', line: 200 }, { src: ' a b c d e', line: 2000 }, ]; const expected = [ '\u001b[34m2:\u001b[39m' + ' ' + '\u001b[37mfoo bar\u001b[39m', '\u001b[34m200:\u001b[39m' + ' ' + '\u001b[37mbaz wux\u001b[39m', '\u001b[34m2000:\u001b[39m' + ' ' + '\u001b[37m a b c d e\u001b[39m', ]; const results = inputs.map(NiceError._sourceLine.bind(NiceError)); assert.deepEqual(results, expected); done(); }); }); describe('._extractContext', () => { it('can get one line of context from the middle', (done) => { const line = 3; const contextSize = 1; const expected = [ { line: 2, src: 'next := some(1, 2, 3)' }, { line: 3, src: 'def foo(bar) {' }, { line: 4, src: ' -- what language is this?' }, ]; const results = NiceError._extractContext(fakeFile, line, contextSize); assert.deepEqual(results, expected); done(); }); it('can get a size 2 context', (done) => { const line = 3; const contextSize = 2; const expected = [ { line: 1, src: 'some = fake, syntax' }, { line: 2, src: 'next := some(1, 2, 3)' }, { line: 3, src: 'def foo(bar) {' }, { line: 4, src: ' -- what language is this?' }, { line: 5, src: '}' }, ]; const results = NiceError._extractContext(fakeFile, line, contextSize); assert.deepEqual(results, expected); done(); }); it('can gets a size 2 context with 1 line below it', (done) => { const line = 2; const contextSize = 2; const expected = [ { line: 1, src: 'some = fake, syntax' }, { line: 2, src: 'next := some(1, 2, 3)' }, { line: 3, src: 'def foo(bar) {' }, { line: 4, src: ' -- what language is this?' }, ]; const results = NiceError._extractContext(fakeFile, line, contextSize); assert.deepEqual(results, expected); done(); }); it('can gets a size 3 context with 0 lines below it', (done) => { const line = 1; const contextSize = 3; const expected = [ { line: 1, src: 'some = fake, syntax' }, { line: 2, src: 'next := some(1, 2, 3)' }, { line: 3, src: 'def foo(bar) {' }, { line: 4, src: ' -- what language is this?' }, ]; const results = NiceError._extractContext(fakeFile, line, contextSize); assert.deepEqual(results, expected); done(); }); it('can gets a size 3 context with 0 lines after it', (done) => { const line = 6; const contextSize = 3; const expected = [ { line: 3, src: 'def foo(bar) {' }, { line: 4, src: ' -- what language is this?' }, { line: 5, src: '}' }, { line: 6, src: '' }, ]; const results = NiceError._extractContext(fakeFile, line, contextSize); assert.deepEqual(results, expected); done(); }); it('returns an empty array if line out of bounds', (done) => { const line = 7; const contextSize = 3; const expected = []; const results = NiceError._extractContext(fakeFile, line, contextSize); assert.deepEqual(results, expected); done(); }); }); describe('.toString', () => { const message = 'This is an error message'; it('is compatible with a basic Error', (done) => { const error = new NiceError(message); const result = error.toString(); assert.deepEqual(result, message); done(); }); it('only displays the basic message', (done) => { const error = new NiceError(message, { description: 'Some long description', suggestions: [ 'Suggestion A', 'Suggestion B', ], src: { filename: 'fakety.txt', contents: 'File contents', line: 0, column: 0, }, }); const result = error.toString(); assert.deepEqual(result, message); done(); }); }); describe('.niceString', () => { const message = 'Some kinda message'; const description = `A much longer description here that may span \ many lines and be just really ridiculously long in order to completely \ explain what's going on.` const filename = './fake.dx'; const line = 2; const column = 6; const suggestions = [ 'Always call your mother', 'Never lie to your mother about being robbed in Rio', ]; let error; beforeEach(() => { error = new NiceError(message, { description: description, src: { filename, contents: fakeFile, line, column, }, suggestions, }); }); it('shows the description if present', (done) => { error.src = null; error.suggestions = null; const expected = `\ ${message} ${description} `; const result = stripAnsi(error.niceString()); assert.deepEqual(result, expected); done(); }); it('returns a carrot in the right place with source', (done) => { error.suggestions = null; const expected = `\ ${message} ${description} In ./fake.dx, line 2, column 6: 1: some = fake, syntax 2: next := some(1, 2, 3) ^ 3: def foo(bar) { 4: -- what language is this? `; const result = stripAnsi(error.niceString()); assert.deepEqual(result, expected); done(); }); it('returns a list of suggestions if present', (done) => { error.src = null; error.description = null; const expected = `\ ${message} Suggestions: ➤ ${suggestions[0]} ➤ ${suggestions[1]} `; const results = stripAnsi(error.niceString()); assert.deepEqual(results, expected); done(); }); it('shows both suggestions and source if present', (done) => { error.description = null; error.suggestions.shift(); error.src.line = 5; error.src.column = 1; const expected = `\ ${message} In ./fake.dx, line 5, column 1: 2: next := some(1, 2, 3) 3: def foo(bar) { 4: -- what language is this? 5: } ^ 6: \ Suggestion: ➤ ${suggestions[0]} `; /* Note: there's an extra space in the string literal above on * the line starting with "6:". This could be removed, but a lot * of text editors are set to remove trailing spaces on save, so * a backslash and an extra newline are a workaround to avoid * the editor mucking with it. */ const results = stripAnsi(error.niceString({ contextSize: 3 })); assert.deepEqual(results, expected); done(); }); }); }); ================================================ FILE: cli/test/version.spec.js ================================================ 'use strict'; const versionCommand = require('../src/version'); const assert = require('assert'); const sinon = require('sinon'); const pkg = require('../package.json'); describe('hz version', () => { beforeEach(() => sinon.stub(console, 'info')); afterEach(() => console.info.restore()); it('prints the version and exits', () => versionCommand.run().then(() => assert.equal(console.info.args[0][0], pkg.version))); }); ================================================ FILE: client/.eslintrc.js ================================================ const OFF = 0; const WARN = 1; const ERROR = 2; module.exports = { extends: "../.eslintrc.js", rules: { "arrow-parens": [ ERROR, "as-needed" ], "no-confusing-arrow": [ OFF ], "no-use-before-define": [ OFF ], "semi": [ ERROR, "never" ], "max-len": [ ERROR, 80, 2 ], }, env: { "browser": true, "commonjs": true, "es6": true, "mocha": true, }, parser: "babel-eslint", }; ================================================ FILE: client/.gitignore ================================================ # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release build/ # Dependency directory # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git node_modules # database stuff rethinkdb_data/ rethinkdb_data_test/ ================================================ FILE: client/README.md ================================================ # Horizon Client Library The Horizon client library. Built to interact with the [Horizon Server](/server) API. Provides all the tooling to build a fully-functional and reactive front-end web application. ## Building Running `npm install` for the first time will build the browser bundle and lib files. 1. `npm install` 2. `npm run dev` (or `npm run build` or `npm run compile`, see below) ### Build Options Command | Description --------------------|---------------------------- npm run build | Build dist/horizon.js minified production browser bundle npm run builddebug | Build with webpack and output debug logging npm run compile | Compile src to lib for CommonJS module loaders (such as webpack, browserify) npm run coverage | Run code coverage tool - `istanbul` npm run dev | Watch directory for changes, build dist/horizon.js unminified browser bundle npm run devtest | Serve `dist` directory to build app and continuously run tests npm test | Run tests in node npm run lint -s | Lint src npm run test | Run tests ## Running tests * `npm test` or open `dist/test.html` in your browser after getting setup and while you also have Horizon server with the `--dev` flag running on `localhost`. * You can spin up a dev server by cloning the horizon repo and running `node serve.js` in `test` directory in repo root. Then tests can be accessed from . Source maps work properly when served via http, not from file system. You can test the production version via `NODE_ENV=production node serve.js`. You may want to use `test/setupDev.sh` to set the needed local npm links for development. ## Docs ### Getting Started [samuelhughes.com/rethinkdb/horizon-docs/docs/getting-started.html](http://samuelhughes.com/rethinkdb/horizon-docs/docs/getting-started.html) ### APIs * Horizon API - [samuelhughes.com/rethinkdb/horizon-docs/api/horizon.html](http://samuelhughes.com/rethinkdb/horizon-docs/api/horizon.html) * Collection API - [samuelhughes.com/rethinkdb/horizon-docs/api/horizon.html](http://samuelhughes.com/rethinkdb/horizon-docs/api/horizon.html) ## Users and Groups [samuelhughes.com/rethinkdb/horizon-docs/docs/users.html](http://samuelhughes.com/rethinkdb/horizon-docs/docs/users.html) ## Setting Permissions [samuelhughes.com/rethinkdb/horizon-docs/docs/permissions.html](http://samuelhughes.com/rethinkdb/horizon-docs/docs/permissions.html) ### Clearing tokens Sometimes you may wish to delete all authentication tokens from localStorage. You can do that with: ``` js // Note the 'H' Horizon.clearAuthTokens() ``` ================================================ FILE: client/index.d.ts ================================================ import { Observable } from 'rxjs'; declare namespace hz { interface Feed { watch (options?: { rawChanges: boolean }): Observable; fetch (): Observable; } type Bound = 'open' | 'closed'; type Direction = 'ascending' | 'descending'; type Primitive = boolean | number | string | Date; type IdValue = Primitive | Primitive[] | { id: Primitive }; type WriteOp = Object | Object[]; interface TermBase extends Feed { find (value: IdValue): TermBase; findAll (...values: IdValue[]): TermBase; order (fields: string[], direction?: Direction): TermBase; limit (size: Number): TermBase; above (spec: any, bound?: Bound): TermBase; below (spec: any, bound?: Bound): TermBase; } interface Collection extends TermBase { store (docs: WriteOp): Observable; upsert (docs: WriteOp): Observable; insert (docs: WriteOp): Observable; replace (docs: WriteOp): Observable; update (docs: WriteOp): Observable; remove (docs: IdValue): Observable; removeAll (docs: IdValue[]): Observable; } interface User extends Feed {} interface HorizonInstance { (name: string): Collection; currentUser (): User; hasAuthToken (): boolean; authEndpoint (name: string): Observable; aggregate (aggs: any): TermBase; model (fn: Function): TermBase; disconnect (): void; connect (): void; status (): Observable; onReady (): Observable; onDisconnected (): Observable; onSocketError (): Observable; } interface HorizonOptions { host?: string; path?: string; secure?: boolean; authType?: string; lazyWrites?: boolean; keepalive?: number; WebSocketCtor?: any; } interface Horizon { (options: HorizonOptions): HorizonInstance; clearAuthTokens (): void; } } export type HorizonOptions = hz.HorizonOptions; export type HorizonInstance = hz.HorizonInstance; export type TermBase = hz.TermBase; export type Collection = hz.Collection; export type User = hz.User; declare var Horizon: hz.Horizon; export default Horizon; ================================================ FILE: client/package.json ================================================ { "name": "@horizon/client", "version": "2.0.0", "description": "RethinkDB Horizon is an open-source developer platform for building realtime, scalable web apps.", "scripts": { "coverage": "cross-env NODE_ENV=test nyc mocha test/test.js", "dev": "webpack --watch --progress --colors", "devtest": "nodemon --watch dist --exec 'npm test -- --reporter dot && npm run lint -s'", "builddebug": "webpack --progress --colors --display-modules --display-reasons", "build": "cross-env NODE_ENV=production webpack --progress --colors", "compile": "node ./scripts/compile.js", "lint": "eslint src", "test": "mocha dist/test.js --inline-diffs --timeout 10000", "prepublish": "npm run compile && npm run build" }, "dependencies": { "babel-runtime": "^6.6.1", "core-js": "^2.1.0", "deep-equal": "^1.0.1", "es6-promise": "^3.2.1", "is-plain-object": "^2.0.1", "rxjs": "5.0.0-beta.11", "snake-case": "^2.1.0", "ws": "^1.1.0" }, "engines": { "node": ">=4.0.0" }, "devDependencies": { "babel-cli": "^6.11.4", "babel-core": "^6.10.4", "babel-eslint": "^6.0.0-beta", "babel-loader": "^6.2.4", "babel-plugin-istanbul": "^1.0.3", "babel-plugin-transform-runtime": "^6.6.0", "babel-preset-es2015": "^6.6.0", "babel-preset-es2015-loose": "^7.0.0", "babel-register": "^6.9.0", "chai": "^3.5.0", "copy-webpack-plugin": "^3.0.1", "cross-env": "^2.0.0", "eslint": "^7.3.1", "exports-loader": "^0.6.3", "imports-loader": "^0.6.5", "istanbul": "^0.4.2", "lodash.clonedeep": "^4.4.1", "lodash.sortby": "^4.6.1", "mocha": "^2.5.3", "nodemon": "^1.9.1", "nyc": "^7.0.0", "shelljs": "^0.7.0", "source-map-support": "^0.4.0", "webpack": "^1.12.14" }, "main": "lib/index.js", "jsmain:next": "src/index.js", "repository": { "type": "git", "url": "https://github.com/rethinkdb/horizon.git" }, "author": "RethinkDB", "license": "ISC", "bugs": { "url": "https://github.com/rethinkdb/horizon/issues" }, "homepage": "https://github.com/rethinkdb/horizon", "files": [ "dist/horizon.js", "dist/horizon.js.map", "dist/horizon-dev.js", "dist/horizon-dev.js.map", "dist/horizon-core.js", "dist/horizon-core.js.map", "dist/horizon-core-dev.js", "dist/horizon-core-dev.js.map", "lib/*" ], "babel": { "presets": [ "es2015-loose", { "plugins": [ [ "transform-runtime", { "polyfill": false } ] ] } ], "env": { "test": { "plugins": [ "istanbul" ] } } }, "nyc": { "all": true, "statements": 82.02, "branches": 72.51, "functions": 87.42, "lines": 81.89, "cache": true, "check-coverage": true, "include": [ "src/**/*.js" ], "require": [ "babel-register" ], "reporter": [ "lcov", "text-summary", "html" ], "sourceMap": false, "instrument": false } } ================================================ FILE: client/scripts/compile.js ================================================ require('shelljs/global') // remove existing lib files rm('-rf', 'lib/**/*') // compile with babel if (exec('babel src --out-dir lib --extends src/.babelrc --source-maps true').code !== 0) { echo('error: babel couldn\'t build source, if EACCESS error, check access rights') exit(1) } ================================================ FILE: client/src/ast.js ================================================ import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/empty' import 'rxjs/add/operator/publishReplay' import 'rxjs/add/operator/scan' import 'rxjs/add/operator/filter' import 'rxjs/add/operator/map' import 'rxjs/add/operator/toArray' import 'rxjs/add/operator/defaultIfEmpty' import 'rxjs/add/operator/ignoreElements' import 'rxjs/add/operator/merge' import 'rxjs/add/operator/mergeMap' import 'rxjs/add/operator/take' import snakeCase from 'snake-case' import deepEqual from 'deep-equal' import checkArgs from './util/check-args' import validIndexValue from './util/valid-index-value.js' import { serialize } from './serialization.js' import watchRewrites from './hacks/watch-rewrites' /** @this TermBase Validation check to throw an exception if a method is chained onto a query that already has it. It belongs to TermBase, but we don't want to pollute the objects with it (since it isn't useful to api users), so it's dynamically bound with .call inside methods that use it. */ function checkIfLegalToChain(key) { if (this._legalMethods.indexOf(key) === -1) { throw new Error(`${key} cannot be called on the current query`) } if (snakeCase(key) in this._query) { throw new Error(`${key} has already been called on this query`) } } // Abstract base class for terms export class TermBase { constructor(sendRequest, query, legalMethods) { this._sendRequest = sendRequest this._query = query this._legalMethods = legalMethods } toString() { let string = `Collection('${this._query.collection}')` if (this._query.find) { string += `.find(${JSON.stringify(this._query.find)})` } if (this._query.find_all) { string += `.findAll(${JSON.stringify(this._query.find_all)})` } if (this._query.order) { string += `.order(${JSON.stringify(this._query.order[0])}, ` + `${JSON.stringify(this._query.order[1])})` } if (this._query.above) { string += `.above(${JSON.stringify(this.query.above[0])}, ` + `${JSON.stringify(this.query.above[1])})` } if (this._query.below) { string += `.below(${JSON.stringify(this.query.below[0])}, ` + `${JSON.stringify(this.query.below[1])})` } if (this._query.limit) { string += `.limit(${JSON.stringify(this._query.limit)})` } return string } // Returns a sequence of the result set. Every time it changes the // updated sequence will be emitted. If raw change objects are // needed, pass the option 'rawChanges: true'. An observable is // returned which will lazily emit the query when it is subscribed // to watch({ rawChanges = false } = {}) { const query = watchRewrites(this, this._query) const raw = this._sendRequest('subscribe', query) if (rawChanges) { return raw } else { return makePresentable(raw, this._query) } } // Grab a snapshot of the current query (non-changefeed). Emits an // array with all results. An observable is returned which will // lazily emit the query when subscribed to fetch() { const raw = this._sendRequest('query', this._query).map(val => { delete val.$hz_v$ return val }) if (this._query.find) { return raw.defaultIfEmpty(null) } else { return raw.toArray() } } findAll(...fieldValues) { checkIfLegalToChain.call(this, 'findAll') checkArgs('findAll', arguments, { maxArgs: 100 }) return new FindAll(this._sendRequest, this._query, fieldValues) } find(idOrObject) { checkIfLegalToChain.call(this, 'find') checkArgs('find', arguments) return new Find(this._sendRequest, this._query, idOrObject) } order(fields, direction = 'ascending') { checkIfLegalToChain.call(this, 'order') checkArgs('order', arguments, { minArgs: 1, maxArgs: 2 }) return new Order(this._sendRequest, this._query, fields, direction) } above(aboveSpec, bound = 'closed') { checkIfLegalToChain.call(this, 'above') checkArgs('above', arguments, { minArgs: 1, maxArgs: 2 }) return new Above(this._sendRequest, this._query, aboveSpec, bound) } below(belowSpec, bound = 'open') { checkIfLegalToChain.call(this, 'below') checkArgs('below', arguments, { minArgs: 1, maxArgs: 2 }) return new Below(this._sendRequest, this._query, belowSpec, bound) } limit(size) { checkIfLegalToChain.call(this, 'limit') checkArgs('limit', arguments) return new Limit(this._sendRequest, this._query, size) } } // Turn a raw observable of server responses into user-presentable events // // `observable` is the base observable with full responses coming from // the HorizonSocket // `query` is the value of `options` in the request function makePresentable(observable, query) { // Whether the entire data structure is in each change const pointQuery = Boolean(query.find) if (pointQuery) { let hasEmitted = false const seedVal = null // Simplest case: just pass through new_val return observable .filter(change => !hasEmitted || change.type !== 'state') .scan((previous, change) => { hasEmitted = true if (change.new_val != null) { delete change.new_val.$hz_v$ } if (change.old_val != null) { delete change.old_val.$hz_v$ } if (change.state === 'synced') { return previous } else { return change.new_val } }, seedVal) } else { const seedVal = { emitted: false, val: [] } return observable .scan((state, change) => { if (change.new_val != null) { delete change.new_val.$hz_v$ } if (change.old_val != null) { delete change.old_val.$hz_v$ } if (change.state === 'synced') { state.emitted = true } state.val = applyChange(state.val.slice(), change) return state }, seedVal) .filter(state => state.emitted) .map(x => x.val) } } export function applyChange(arr, change) { switch (change.type) { case 'remove': case 'uninitial': { // Remove old values from the array if (change.old_offset != null) { arr.splice(change.old_offset, 1) } else { const index = arr.findIndex(x => deepEqual(x.id, change.old_val.id)) if (index === -1) { // Programming error. This should not happen throw new Error( `change couldn't be applied: ${JSON.stringify(change)}`) } arr.splice(index, 1) } break } case 'add': case 'initial': { // Add new values to the array if (change.new_offset != null) { // If we have an offset, put it in the correct location arr.splice(change.new_offset, 0, change.new_val) } else { // otherwise for unordered results, push it on the end arr.push(change.new_val) } break } case 'change': { // Modify in place if a change is happening if (change.old_offset != null) { // Remove the old document from the results arr.splice(change.old_offset, 1) } if (change.new_offset != null) { // Splice in the new val if we have an offset arr.splice(change.new_offset, 0, change.new_val) } else { // If we don't have an offset, find the old val and // replace it with the new val const index = arr.findIndex(x => deepEqual(x.id, change.old_val.id)) if (index === -1) { // indicates a programming bug. The server gives us the // ordering, so if we don't find the id it means something is // buggy. throw new Error( `change couldn't be applied: ${JSON.stringify(change)}`) } arr[index] = change.new_val } break } case 'state': { // This gets hit if we have not emitted yet, and should // result in an empty array being output. break } default: throw new Error( `unrecognized 'type' field from server ${JSON.stringify(change)}`) } return arr } /** @this Collection Implements writeOps for the Collection class */ function writeOp(name, args, documents) { checkArgs(name, args) let isBatch = true let wrappedDocs = documents if (!Array.isArray(documents)) { // Wrap in an array if we need to wrappedDocs = [ documents ] isBatch = false } else if (documents.length === 0) { // Don't bother sending no-ops to the server return Observable.empty() } const options = Object.assign( {}, this._query, { data: serialize(wrappedDocs) }) let observable = this._sendRequest(name, options) if (isBatch) { // If this is a batch writeOp, each document may succeed or fail // individually. observable = observable.map( resp => resp.error ? new Error(resp.error) : resp) } else { // If this is a single writeOp, the entire operation should fail // if any fails. const _prevOb = observable observable = Observable.create(subscriber => { _prevOb.subscribe({ next(resp) { if (resp.error) { // TODO: handle error ids when we get them subscriber.error(new Error(resp.error)) } else { subscriber.next(resp) } }, error(err) { subscriber.error(err) }, complete() { subscriber.complete() }, }) }) } if (!this._lazyWrites) { // Need to buffer response since this becomes a hot observable and // when we subscribe matters observable = observable.publishReplay().refCount() observable.subscribe() } return observable } export class Collection extends TermBase { constructor(sendRequest, collectionName, lazyWrites) { const query = { collection: collectionName } const legalMethods = [ 'find', 'findAll', 'order', 'above', 'below', 'limit' ] super(sendRequest, query, legalMethods) this._lazyWrites = lazyWrites } store(documents) { return writeOp.call(this, 'store', arguments, documents) } upsert(documents) { return writeOp.call(this, 'upsert', arguments, documents) } insert(documents) { return writeOp.call(this, 'insert', arguments, documents) } replace(documents) { return writeOp.call(this, 'replace', arguments, documents) } update(documents) { return writeOp.call(this, 'update', arguments, documents) } remove(documentOrId) { const wrapped = validIndexValue(documentOrId) ? { id: documentOrId } : documentOrId return writeOp.call(this, 'remove', arguments, wrapped) } removeAll(documentsOrIds) { if (!Array.isArray(documentsOrIds)) { throw new Error('removeAll takes an array as an argument') } const wrapped = documentsOrIds.map(item => { if (validIndexValue(item)) { return { id: item } } else { return item } }) return writeOp.call(this, 'removeAll', arguments, wrapped) } } export class Find extends TermBase { constructor(sendRequest, previousQuery, idOrObject) { const findObject = validIndexValue(idOrObject) ? { id: idOrObject } : idOrObject const query = Object.assign({}, previousQuery, { find: findObject }) super(sendRequest, query, []) } } export class FindAll extends TermBase { constructor(sendRequest, previousQuery, fieldValues) { const wrappedFields = fieldValues .map(item => validIndexValue(item) ? { id: item } : item) const options = { find_all: wrappedFields } const findAllQuery = Object.assign({}, previousQuery, options) let legalMethods if (wrappedFields.length === 1) { legalMethods = [ 'order', 'above', 'below', 'limit' ] } else { // The vararg version of findAll cannot have anything chained to it legalMethods = [] } super(sendRequest, findAllQuery, legalMethods) } } export class Above extends TermBase { constructor(sendRequest, previousQuery, aboveSpec, bound) { const option = { above: [ aboveSpec, bound ] } const query = Object.assign({}, previousQuery, option) const legalMethods = [ 'findAll', 'order', 'below', 'limit' ] super(sendRequest, query, legalMethods) } } export class Below extends TermBase { constructor(sendRequest, previousQuery, belowSpec, bound) { const options = { below: [ belowSpec, bound ] } const query = Object.assign({}, previousQuery, options) const legalMethods = [ 'findAll', 'order', 'above', 'limit' ] super(sendRequest, query, legalMethods) } } export class Order extends TermBase { constructor(sendRequest, previousQuery, fields, direction) { const wrappedFields = Array.isArray(fields) ? fields : [ fields ] const options = { order: [ wrappedFields, direction ] } const query = Object.assign({}, previousQuery, options) const legalMethods = [ 'findAll', 'above', 'below', 'limit' ] super(sendRequest, query, legalMethods) } } export class Limit extends TermBase { constructor(sendRequest, previousQuery, size) { const query = Object.assign({}, previousQuery, { limit: size }) // Nothing is legal to chain after .limit super(sendRequest, query, []) } } export class UserDataTerm { constructor(hz, handshake, socket) { this._hz = hz this._before = socket.ignoreElements().merge(handshake) } _query(userId) { return this._hz('users').find(userId) } fetch() { return this._before.mergeMap(handshake => { if (handshake.id == null) { throw new Error('Unauthenticated users have no user document') } else { return this._query(handshake.id).fetch() } }).take(1) // necessary so that we complete, since _before is // infinite } watch(...args) { return this._before.mergeMap(handshake => { if (handshake.id === null) { throw new Error('Unauthenticated users have no user document') } else { return this._query(handshake.id).watch(...args) } }) } } ================================================ FILE: client/src/auth.js ================================================ import queryParse from './util/query-parse' import { Observable } from 'rxjs/Observable' import 'rxjs/add/operator/do' import 'rxjs/add/operator/map' import 'rxjs/add/observable/dom/ajax' const HORIZON_JWT = 'horizon-jwt' /** @this Horizon **/ export function authEndpoint(name) { const endpointForName = methods => { if (methods.hasOwnProperty(name)) { return this._root + methods[name] } else { throw new Error(`Unconfigured auth type: ${name}`) } } if (!this._authMethods) { return Observable.ajax(`${this._horizonPath}/auth_methods`) .map(ajax => ajax.response) .do(authMethods => { this._authMethods = authMethods }).map(endpointForName) } else { return Observable.of(this._authMethods).map(endpointForName) } } // Simple shim to make a Map look like local/session storage export class FakeStorage { constructor() { this._storage = new Map() } setItem(a, b) { return this._storage.set(a, b) } getItem(a) { return this._storage.get(a) } removeItem(a) { return this._storage.delete(a) } } function getStorage(storeLocally = true) { let storage try { if (!storeLocally || typeof window !== 'object' || window.localStorage === undefined) { storage = new FakeStorage() } else { // Mobile safari in private browsing has a localStorage, but it // has a size limit of 0 window.localStorage.setItem('$$fake', 1) window.localStorage.removeItem('$$fake') storage = window.localStorage } } catch (error) { if (window.sessionStorage === undefined) { storage = new FakeStorage() } else { storage = window.sessionStorage } } return storage } export class TokenStorage { constructor({ authType = 'token', storage = getStorage(authType.storeLocally), path = 'horizon' } = {}) { this._storage = storage this._path = path if (typeof authType === 'string') { this._authType = authType } else { this._authType = 'token' this.set(authType.token) } } _getHash() { const val = this._storage.getItem(HORIZON_JWT) if (val == null) { return {} } else { return JSON.parse(val) } } _setHash(hash) { this._storage.setItem(HORIZON_JWT, JSON.stringify(hash)) } set(jwt) { const current = this._getHash() current[this._path] = jwt this._setHash(current) } get() { return this._getHash()[this._path] } remove() { const current = this._getHash() delete current[this._path] this._setHash(current) } setAuthFromQueryParams() { const parsed = typeof window !== 'undefined' && typeof window.location !== 'undefined' ? queryParse(window.location.search) : {} if (parsed.horizon_token != null) { this.set(parsed.horizon_token) } } // Handshake types are implemented here handshake() { // If we have a token, we should send it rather than requesting a // new one const token = this.get() if (token != null) { return { method: 'token', token } } else if (this._authType === 'token') { throw new Error( 'Attempting to authenticate with a token, but no token is present') } else { return { method: this._authType } } } // Whether there is an auth token for the provided authType hasAuthToken() { const token = this.get() if (!token) { return false } try { const meta = JSON.parse(atob(token.split('.')[1])) const exp = meta.exp const now = new Date().getTime() / 1000 return (now < exp) } catch (e) { return false } } } export function clearAuthTokens() { return getStorage().removeItem(HORIZON_JWT) } ================================================ FILE: client/src/hacks/watch-rewrites.js ================================================ /* Some common queries run on an entire collection or on a collection of indeterminate size. RethinkDB doesn't actually keep track of the ordering of these queries when sending changes. The initial changes will be ordered, but subsequent changes come in arbitrary order and don't respect the ordering of the query. So, for convenience, we add a very high limit so that the server will keep track of the order for us. Note: queries like collection.order(field).watch are not reasonable in production systems. You should add an explicit limit. */ export default function watchRewrites(self, query) { // The only query type at the moment that doesn't get these rewrites // is find, since it returns a single document if (query.find === undefined && query.order !== undefined && query.limit === undefined) { const limit = self.constructor.IMPLICIT_LIMIT || 100000 // Need to copy the object, since it could be reused return Object.assign({ limit }, query) } else { return query } } ================================================ FILE: client/src/index-polyfill.js ================================================ // Ensures these features are present or polyfilled // See http://kangax.github.io/compat-table/es6/ require('core-js/fn/array/from') require('core-js/fn/array/find-index') require('core-js/fn/array/keys') require('core-js/fn/object/assign') // Export rxjs globally and add all operators to Observable if (typeof window !== 'undefined') { window.Rx = require('rxjs') } else if (typeof global !== 'undefined') { global.Rx = require('rxjs') } module.exports = require('./index') ================================================ FILE: client/src/index.js ================================================ import 'rxjs/add/observable/of' import 'rxjs/add/observable/from' import 'rxjs/add/operator/catch' import 'rxjs/add/operator/concatMap' import 'rxjs/add/operator/map' import 'rxjs/add/operator/filter' import { Collection, UserDataTerm } from './ast' import { HorizonSocket } from './socket' import { authEndpoint, TokenStorage, clearAuthTokens } from './auth' import { aggregate, model } from './model' const defaultHost = typeof window !== 'undefined' && window.location && `${window.location.host}` || 'localhost:8181' const defaultSecure = typeof window !== 'undefined' && window.location && window.location.protocol === 'https:' || false function Horizon({ host = defaultHost, secure = defaultSecure, path = 'horizon', lazyWrites = false, authType = 'unauthenticated', keepalive = 60, WebSocketCtor = WebSocket, } = {}) { // If we're in a redirection from OAuth, store the auth token for // this user in localStorage. const tokenStorage = new TokenStorage({ authType, path }) tokenStorage.setAuthFromQueryParams() const url = `ws${secure ? 's' : ''}:\/\/${host}\/${path}` const socket = new HorizonSocket({ url, handshakeMaker: tokenStorage.handshake.bind(tokenStorage), keepalive, WebSocketCtor, }) // Store whatever token we get back from the server when we get a // handshake response socket.handshake.subscribe({ next(handshake) { if (authType !== 'unauthenticated') { tokenStorage.set(handshake.token) } }, error(err) { if (/JsonWebTokenError|TokenExpiredError/.test(err.message)) { console.error('Horizon: clearing token storage since auth failed') tokenStorage.remove() } }, }) // This is the object returned by the Horizon function. It's a // function so we can construct a collection simply by calling it // like horizon('my_collection') function horizon(name) { return new Collection(sendRequest, name, lazyWrites) } horizon.currentUser = () => new UserDataTerm(horizon, socket.handshake, socket) horizon.disconnect = () => { socket.complete() } // Dummy subscription to force it to connect to the // server. Optionally provide an error handling function if the // socket experiences an error. // Note: Users of the Observable interface shouldn't need this horizon.connect = ( onError = err => { console.error(`Received an error: ${err}`) } ) => { socket.subscribe( () => {}, onError ) } // Either subscribe to status updates, or return an observable with // the current status and all subsequent status changes. horizon.status = subscribeOrObservable(socket.status) // Convenience method for finding out when disconnected horizon.onDisconnected = subscribeOrObservable( socket.status.filter(x => x.type === 'disconnected')) // Convenience method for finding out when ready horizon.onReady = subscribeOrObservable( socket.status.filter(x => x.type === 'ready')) // Convenience method for finding out when an error occurs horizon.onSocketError = subscribeOrObservable( socket.status.filter(x => x.type === 'error')) horizon.utensils = { sendRequest, tokenStorage, handshake: socket.handshake, } Object.freeze(horizon.utensils) horizon._authMethods = null horizon._root = `http${(secure) ? 's' : ''}://${host}` horizon._horizonPath = `${horizon._root}/${path}` horizon.authEndpoint = authEndpoint horizon.hasAuthToken = tokenStorage.hasAuthToken.bind(tokenStorage) horizon.aggregate = aggregate horizon.model = model return horizon // Sends a horizon protocol request to the server, and pulls the data // portion of the response out. function sendRequest(type, options) { // Both remove and removeAll use the type 'remove' in the protocol const normalizedType = type === 'removeAll' ? 'remove' : type return socket .hzRequest({ type: normalizedType, options }) // send the raw request .takeWhile(resp => resp.state !== 'complete') } } function subscribeOrObservable(observable) { return (...args) => { if (args.length > 0) { return observable.subscribe(...args) } else { return observable } } } Horizon.Socket = HorizonSocket Horizon.clearAuthTokens = clearAuthTokens module.exports = Horizon ================================================ FILE: client/src/model.js ================================================ import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/of' import 'rxjs/add/observable/forkJoin' import 'rxjs/add/observable/combineLatest' import 'rxjs/add/operator/map' // Other imports import isPlainObject from 'is-plain-object' // Unlike normal queries' .watch(), we don't support rawChanges: true // for aggregates function checkWatchArgs(args) { if (args.length > 0) { throw new Error(".watch() on aggregates doesn't support arguments!") } } function isTerm(term) { return typeof term.fetch === 'function' && typeof term.watch === 'function' } function isPromise(term) { return typeof term.then === 'function' } function isObservable(term) { return typeof term.subscribe === 'function' && typeof term.lift === 'function' } // Whether an object is primitive. We consider functions // non-primitives, lump Dates and ArrayBuffers into primitives. function isPrimitive(value) { if (value === null) { return true } if (value === undefined) { return false } if (typeof value === 'function') { return false } if ([ 'boolean', 'number', 'string' ].indexOf(typeof value) !== -1) { return true } if (value instanceof Date || value instanceof ArrayBuffer) { return true } return false } // Simple wrapper for primitives. Just emits the primitive class PrimitiveTerm { constructor(value) { this._value = value } toString() { return this._value.toString() } fetch() { return Observable.of(this._value) } watch(...watchArgs) { checkWatchArgs(watchArgs) return Observable.of(this._value) } } // Simple wrapper for observables to normalize the // interface. Everything in an aggregate tree should be one of these // term-likes class ObservableTerm { constructor(value) { this._value = value } toString() { return this._value.toString() } fetch() { return Observable.from(this._value) } watch(...watchArgs) { checkWatchArgs(watchArgs) return Observable.from(this._value) } } // Handles aggregate syntax like [ query1, query2 ] class ArrayTerm { constructor(value) { // Ensure this._value is an array of Term this._value = value.map(x => aggregate(x)) } _reducer(...args) { return args } _query(operation) { return this._value.map(x => x[operation]()) } toString() { return `[ ${this._query('toString').join(', ')} ]` } fetch() { if (this._value.length === 0) { return Observable.empty() } const qs = this._query('fetch') return Observable.forkJoin(...qs, this._reducer) } watch(...watchArgs) { checkWatchArgs(watchArgs) if (this._value.length === 0) { return Observable.empty() } const qs = this._query('watch') return Observable.combineLatest(...qs, this._reducer) } } class AggregateTerm { constructor(value) { // Ensure this._value is an array of [ key, Term ] pairs this._value = Object.keys(value).map(k => [ k, aggregate(value[k]) ]) } _reducer(...pairs) { return pairs.reduce((prev, [ k, x ]) => { prev[k] = x return prev }, {}) } _query(operation) { return this._value.map( ([ k, term ]) => term[operation]().map(x => [ k, x ])) } toString() { const s = this._value.map(([ k, term ]) => `'${k}': ${term}`) return `{ ${s.join(', ')} }` } fetch() { if (this._value.length === 0) { return Observable.of({}) } const qs = this._query('fetch') return Observable.forkJoin(...qs, this._reducer) } watch(...watchArgs) { checkWatchArgs(watchArgs) if (this._value.length === 0) { return Observable.of({}) } const qs = this._query('watch') return Observable.combineLatest(...qs, this._reducer) } } export function aggregate(spec) { if (isTerm(spec)) { return spec } if (isObservable(spec) || isPromise(spec)) { return new ObservableTerm(spec) } if (isPrimitive(spec)) { return new PrimitiveTerm(spec) } if (Array.isArray(spec)) { return new ArrayTerm(spec) } if (isPlainObject(spec)) { return new AggregateTerm(spec) } throw new Error(`Can't make an aggregate with ${spec} in it`) } export function model(constructor) { return (...args) => aggregate(constructor(...args)) } ================================================ FILE: client/src/serialization.js ================================================ const PRIMITIVES = [ 'string', 'number', 'boolean', 'function', 'symbol' ] function modifyObject(doc) { Object.keys(doc).forEach(key => { doc[key] = deserialize(doc[key]) }) return doc } export function deserialize(value) { if (value == null) { return value } else if (PRIMITIVES.indexOf(typeof value) !== -1) { return value } else if (Array.isArray(value)) { return value.map(deserialize) } else if (value.$reql_type$ === 'TIME') { const date = new Date() date.setTime(value.epoch_time * 1000) return date } else { return modifyObject(value) } } function jsonifyObject(doc) { Object.keys(doc).forEach(key => { doc[key] = serialize(doc[key]) }) return doc } export function serialize(value) { if (value == null) { return value } else if (PRIMITIVES.indexOf(typeof value) !== -1) { return value } else if (Array.isArray(value)) { return value.map(serialize) } else if (value instanceof Date) { return { $reql_type$: 'TIME', epoch_time: value.getTime() / 1000, // Rethink will serialize this as "+00:00", but accepts Z timezone: 'Z', } } else { return jsonifyObject(value) } } ================================================ FILE: client/src/shim.js ================================================ /* global WebSocket */ // Check for websocket if (typeof WebSocket !== 'undefined') { module.exports.WebSocket = WebSocket } else { module.exports.WebSocket = () => { console.error("Tried to use WebSocket but it isn't defined or polyfilled") } } ================================================ FILE: client/src/socket.js ================================================ import { AsyncSubject } from 'rxjs/AsyncSubject' import { BehaviorSubject } from 'rxjs/BehaviorSubject' import { WebSocketSubject } from 'rxjs/observable/dom/WebSocketSubject' import { Observable } from 'rxjs/Observable' import { Subscription } from 'rxjs/Subscription' import 'rxjs/add/observable/merge' import 'rxjs/add/observable/timer' import 'rxjs/add/operator/filter' import 'rxjs/add/operator/share' import 'rxjs/add/operator/ignoreElements' import 'rxjs/add/operator/concat' import 'rxjs/add/operator/takeWhile' import 'rxjs/add/operator/publish' import { serialize, deserialize } from './serialization.js' const PROTOCOL_VERSION = 'rethinkdb-horizon-v0' // Before connecting the first time const STATUS_UNCONNECTED = { type: 'unconnected' } // After the websocket is opened and handshake is completed const STATUS_READY = { type: 'ready' } // After unconnected, maybe before or after connected. Any socket level error const STATUS_ERROR = { type: 'error' } // Occurs when the socket closes const STATUS_DISCONNECTED = { type: 'disconnected' } class ProtocolError extends Error { constructor(msg, errorCode) { super(msg) this.errorCode = errorCode } toString() { return `${this.message} (Code: ${this.errorCode})` } } // Wraps native websockets with a Subject, which is both an Subscriber // and an Observable (it is bi-directional after all!). This version // is based on the rxjs.observable.dom.WebSocketSubject implementation. export class HorizonSocket extends WebSocketSubject { // Deserializes a message from a string. Overrides the version // implemented in WebSocketSubject resultSelector(e) { return deserialize(JSON.parse(e.data)) } // We're overriding the next defined in AnonymousSubject so we // always serialize the value. When this is called a message will be // sent over the socket to the server. next(value) { const request = JSON.stringify(serialize(value)) super.next(request) } constructor({ url, // Full url to connect to handshakeMaker, // function that returns handshake to emit keepalive = 60, // seconds between keepalive messages WebSocketCtor = WebSocket, // optionally provide a WebSocket constructor } = {}) { super({ url, protocol: PROTOCOL_VERSION, WebSocketCtor, openObserver: { next: () => this.sendHandshake(), }, closeObserver: { next: () => { if (this._handshakeSub) { this._handshakeSub.unsubscribe() this._handshakeSub = null } this.status.next(STATUS_DISCONNECTED) }, }, }) // Completes or errors based on handshake success. Buffers // handshake response for later subscribers (like a Promise) this.handshake = new AsyncSubject() this._handshakeMaker = handshakeMaker this._handshakeSub = null this.keepalive = Observable .timer(keepalive * 1000, keepalive * 1000) .map(n => this.makeRequest({ type: 'keepalive' }).subscribe()) .publish() // This is used to emit status changes that others can hook into. this.status = new BehaviorSubject(STATUS_UNCONNECTED) // Keep track of subscribers so we's can decide when to // unsubscribe. this.requestCounter = 0 // A map from request_ids to an object with metadata about the // request. Eventually, this should allow re-sending requests when // reconnecting. this.activeRequests = new Map() this._output.subscribe({ // This emits if the entire socket errors (usually due to // failure to connect) error: () => this.status.next(STATUS_ERROR), }) } deactivateRequest(req) { return () => { this.activeRequests.delete(req.request_id) return { request_id: req.request_id, type: 'end_subscription' } } } activateRequest(req) { return () => { this.activeRequests.set(req.request_id, req) return req } } filterRequest(req) { return resp => resp.request_id === req.request_id } getRequest(request) { return Object.assign({ request_id: this.requestCounter++ }, request) } // This is a trimmed-down version of multiplex that only listens for // the handshake requestId. It also starts the keepalive observable // and cleans up after it when the handshake is cleaned up. sendHandshake() { if (!this._handshakeSub) { this._handshakeSub = this.makeRequest(this._handshakeMaker()) .subscribe({ next: n => { if (n.error) { this.status.next(STATUS_ERROR) this.handshake.error(new ProtocolError(n.error, n.error_code)) } else { this.status.next(STATUS_READY) this.handshake.next(n) this.handshake.complete() } }, error: e => { this.status.next(STATUS_ERROR) this.handshake.error(e) }, }) // Start the keepalive and make sure it's // killed when the handshake is cleaned up this._handshakeSub.add(this.keepalive.connect()) } return this.handshake } // Incorporates shared logic between the inital handshake request and // all subsequent requests. // * Generates a request id and filters by it // * Send `end_subscription` when observable is unsubscribed makeRequest(rawRequest) { const request = this.getRequest(rawRequest) return super.multiplex( this.activateRequest(request), this.deactivateRequest(request), this.filterRequest(request) ) } // Wrapper around the makeRequest with the following additional // features we need for horizon's protocol: // * Sends handshake on subscription if it hasn't happened already // * Wait for the handshake to complete before sending the request // * Errors when a document with an `error` field is received // * Completes when `state: complete` is received // * Emits `state: synced` as a separate document for easy filtering // * Reference counts subscriptions hzRequest(rawRequest) { return this.sendHandshake().ignoreElements() .concat(this.makeRequest(rawRequest)) .concatMap(resp => { if (resp.error !== undefined) { throw new ProtocolError(resp.error, resp.error_code) } const data = resp.data || [] if (resp.state !== undefined) { // Create a little dummy object for sync notifications data.push({ type: 'state', state: resp.state, }) } return data }) .share() } } ================================================ FILE: client/src/util/check-args.js ================================================ import ordinal from './ordinal.js' // Validation helper export default function checkArgs(name, args, { nullable: nullable = false, minArgs: minArgs = 1, maxArgs: maxArgs = 1 } = {}) { if (minArgs === maxArgs && args.length !== minArgs) { const plural = minArgs === 1 ? '' : 's' throw new Error(`${name} must receive exactly ${minArgs} argument${plural}`) } if (args.length < minArgs) { const plural1 = minArgs === 1 ? '' : 's' throw new Error( `${name} must receive at least ${minArgs} argument${plural1}.`) } if (args.length > maxArgs) { const plural2 = maxArgs === 1 ? '' : 's' throw new Error( `${name} accepts at most ${maxArgs} argument${plural2}.`) } for (let i = 0; i < args.length; i++) { if (!nullable && args[i] === null) { const ordinality = maxArgs !== 1 ? ` ${ordinal(i + 1)}` : '' throw new Error(`The${ordinality} argument to ${name} must be non-null`) } if (args[i] === undefined) { throw new Error( `The ${ordinal(i + 1)} argument to ${name} must be defined`) } } } ================================================ FILE: client/src/util/glob.js ================================================ module.exports = function glob() { return typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : {} } ================================================ FILE: client/src/util/ordinal.js ================================================ export default function ordinal(x) { if ([ 11, 12, 13 ].indexOf(x) !== -1) { return `${x}th` } else if (x % 10 === 1) { return `${x}st` } else if (x % 10 === 2) { return `${x}nd` } else if (x % 10 === 3) { return `${x}rd` } return `${x}th` } ================================================ FILE: client/src/util/query-parse.js ================================================ /* Pulled from @sindresorhus query-string module and reformatted. This is simply to avoid requiring the other methods in the module. MIT License © Sindre Sorhus */ export default function(str) { if (typeof str !== 'string') { return {} } const str2 = str.trim().replace(/^(\?|#|&)/, '') if (!str2) { return {} } return str2.split('&').reduce((ret, param) => { const parts = param.replace(/\+/g, ' ').split('=') // Firefox (pre 40) decodes `%3D` to `=` // https://github.com/sindresorhus/query-string/pull/37 const key = parts.shift() const val = parts.length > 0 ? parts.join('=') : undefined const key2 = decodeURIComponent(key) // missing `=` should be `null`: // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters const val2 = val === undefined ? null : decodeURIComponent(val) if (!ret.hasOwnProperty(key2)) { ret[key2] = val2 } else if (Array.isArray(ret[key2])) { ret[key2].push(val2) } else { ret[key2] = [ ret[key2], val2 ] } return ret }, {}) } ================================================ FILE: client/src/util/valid-index-value.js ================================================ // Checks whether the return value is a valid primary or secondary // index value in RethinkDB. export default function validIndexValue(val) { if (val === null) { return false } if ([ 'boolean', 'number', 'string' ].indexOf(typeof val) !== -1) { return true } if (val instanceof ArrayBuffer) { return true } if (val instanceof Date) { return true } if (Array.isArray(val)) { let isValid = true val.forEach(v => { isValid = isValid && validIndexValue(v) }) return isValid } return false } ================================================ FILE: client/test/above.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion } from './utils' export default function aboveSuite(getData) { return () => { let data before(() => { data = getData() }) // By default `above` is closed it('is a closed bound by default', assertCompletes(() => data.order('id').above({ id: 5 }).fetch() .do(res => compareWithoutVersion(res, [ { id: 5, a: 60 }, { id: 6, a: 50 }, ])) )) // We can also pass that explicitly it('allows "closed" to be passed explicitly', assertCompletes(() => data.order('id').above({ id: 5 }, 'closed').fetch() .do(res => compareWithoutVersion(res, [ { id: 5, a: 60 }, { id: 6, a: 50 }, ])) )) // But we can make it open it('can return an open bounded result', assertCompletes(() => data.order('id').above({ id: 5 }, 'open').fetch() .do(([ res ]) => compareWithoutVersion(res, { id: 6, a: 50 })) )) // Let's try something that returns no values it('returns no results if bound eliminates all documents', assertCompletes(() => data.order('id').above({ id: 7 }).fetch() .do(res => compareWithoutVersion(res, [])) )) // We can chain `above` off a collection it('can be chained from a collection directly', assertCompletes(() => data.above({ id: 5 }).fetch() .do(res => { assert.isArray(res) assert.lengthOf(res, 2) }) )) // Or off other things it('can be chained from a findAll', assertCompletes(() => data.findAll({ a: 20 }).above({ id: 3 }).fetch() .do(res => { assert.isArray(res) assert.lengthOf(res, 2) }) )) // `above` can't include any keys that are in `findAll` it('errors when it contains any keys from the findAll term', assertErrors(() => data.findAll({ a: 20 }).above({ a: 3 }).fetch(), /"a" cannot be used in "order", "above", or "below" when finding by that field/ )) // Let's try it on a non-primary key it('can be used on a non-primary key', assertCompletes(() => data.order([ 'a', 'id' ]).above({ a: 20 }).fetch() .do(res => compareWithoutVersion(res, [ { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, { id: 4, a: 20, b: 3 }, { id: 6, a: 50 }, { id: 5, a: 60 }, ])) )) // Let's try it on a non-primary key, but open it('can be used on non-primary key with open bound', assertCompletes(() => data.order([ 'a', 'id' ]).above({ a: 20 }, 'open').fetch() .do(res => compareWithoutVersion(res, [ { id: 6, a: 50 }, { id: 5, a: 60 }, ])) )) // The key in `above` must be the first key in `order` it('must receive as an argument the first key in the order term', assertErrors(() => data.order([ 'a', 'id' ]).above({ id: 20 }).fetch(), /"above" must be on the same field as the first in "order"/ )) // Passing multiple keys to `above` isn't legal it('errors if multiple keys are passed', assertErrors(() => data.order([ 'a', 'id' ]).above({ a: 20, id: 20 }).fetch(), /"find" is required/ )) // Nor is passing a field that isn't specified in `order` it(`errors if the field passed isn't in the order term`, assertErrors(() => data.order([ 'a', 'id' ]).above({ b: 20 }).fetch(), /"above" must be on the same field as the first in "order"/ )) // If chaining `above/below`, they must be passed the same key it(`errors if it doesn't receive the same key as the below term`, assertErrors(() => data.above({ b: 0 }).below({ a: 100 }).fetch(), /"below" must be on the same field as the first in "order"/ )) // Starting with `null` is not ok it('throws if it is passed null', assertThrows( 'The 1st argument to above must be non-null', () => data.above(null).fetch() )) // Empty value is not ok it('throws if it does not receive an argument', assertThrows( 'above must receive at least 1 argument.', () => data.above().fetch() )) // Bad arguments are not ok it('errors if it receives a non-string argument', assertErrors(() => data.above(1).fetch(), /"find" is required/ )) it('errors if it receives more than one argument', assertErrors(() => data.above({ id: 1 }, 1).fetch(), /"find" is required/ )) }} ================================================ FILE: client/test/aboveSub.js ================================================ import 'rxjs/add/operator/concat' import { assertCompletes, observableInterleave } from './utils' export default function aboveSubscriptionSuite(getData) { return () => { let data before(() => { data = getData() }) // Let's grab a specific document using 'above' it('can get a specific document', assertCompletes(() => observableInterleave({ query: data.above({ id: 1 }).watch(), operations: [ data.store({ id: 1, a: 1 }), data.remove(1), ], expected: [ [], [ { id: 1, a: 1 } ], [], ], }) )) // Let's grab a specific document using 'above' and also test the // 'changed' event. it('can get a document and reflect changes to it', assertCompletes(() => observableInterleave({ query: data.above({id: 1}).watch(), operations: [ data.store({ id: 1, a: 1 }), data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ [], [ { id: 1, a: 1 } ], [ { id: 1, a: 2 } ], [], ], }) )) // Secondary index, open it('can get a document by secondary index with open bound', assertCompletes(() => observableInterleave({ query: data.above({a: 0}, 'open').watch(), operations: [ data.store({ id: 1, a: 0 }) .concat(data.store({ id: 1, a: 1 })), data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ [], [ { id: 1, a: 1 } ], [ { id: 1, a: 2 } ], [], ], }) )) // Let's make sure we don't see events that aren't ours it("doesn't see updates to documents outside its bound", assertCompletes(() => observableInterleave({ query: data.above({ id: 3 }).watch(), operations: [ data.store({ id: 2, a: 1 }) .concat(data.store({ id: 2, a: 2 })) .concat(data.store({ id: 3, val: 'foo' })) .concat(data.remove(2)), data.remove(3), ], expected: [ [], [ { id: 3, val: 'foo' } ], [], ], }) )) // Let's try subscribing to multiple IDs it('can subscribe to multiple ids', assertCompletes(() => observableInterleave({ query: data.above({ id: 1 }).below({ id: 3 }, 'open').watch(), operations: [ data.store({ id: 1, a: 1 }), data.store({ id: 2, a: 1 }) .concat(data.store({ id: 3, a: 1 })), data.store({ id: 1, a: 2 }), data.store({ id: 2, a: 2 }) .concat(data.store({ id: 3, a: 2 })), data.remove(1), data.remove(2) .concat(data.remove(3)), ], expected: [ [], [ { id: 1, a: 1 } ], [ { id: 1, a: 1 }, { id: 2, a: 1 } ], [ { id: 1, a: 2 }, { id: 2, a: 1 } ], [ { id: 1, a: 2 }, { id: 2, a: 2 } ], [ { id: 2, a: 2 } ], [], ], }) )) // Let's make sure initial vals works correctly it('handles initial values correctly', assertCompletes(() => data.store({ id: 1, a: 1 }).concat( observableInterleave({ query: data.above({ id: 1 }).watch(), operations: [ data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ [ { id: 1, a: 1 } ], [ { id: 1, a: 2 } ], [], ], }) ) )) }} ================================================ FILE: client/test/aggregate.js ================================================ import Rx from 'rxjs/Rx' import { assertCompletes, removeAllDataObs, observableInterleave } from './utils' // Raises an exception if corresponding elements in an array don't // have the same elements (in any order) function arrayHasSameElements(a, b) { if (a.length !== b.length) { return false } for (let i = 0; i < a.length; i++) { assert.sameDeepMembers(a[i], b[i]) } } export default function aggregateSuite(getData, getHorizon) { return () => { let data, horizon, hzA, hzB before(() => { data = getData() horizon = getHorizon() hzA = horizon('testA') hzB = horizon('testB') }) afterEach(done => { removeAllDataObs(data) .concat(removeAllDataObs(hzA)) .concat(removeAllDataObs(hzB)) .subscribe({ next() { }, error(err) { done(err) }, complete() { done() }, }) }) it('is equivalent to a subquery if it is not passed an object', assertCompletes(() => { const underlyingQuery = data.order('id').limit(3) return data.insert([ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, ]).concat(observableInterleave({ query: horizon.aggregate(underlyingQuery).fetch(), operations: [], expected: [ [ { id: 1 }, { id: 2 }, { id: 3 } ], ], })) }) ) it('combines multiple queries in an array into one', assertCompletes(() => { const query = horizon.aggregate([ hzA, hzB ]).fetch() const expected = [ [ { id: 1 }, { id: 3 } ], [ { id: 2 }, { id: 4 } ], ] return hzA.insert([ { id: 1 }, { id: 3 }, ]).concat(hzB.insert([ { id: 2 }, { id: 4 }, ])).concat(observableInterleave({ query, operations: [], equality: arrayHasSameElements, expected: [ expected ], })) }) ) it('allows constants in an array spec', assertCompletes(() => { const query = horizon.aggregate([ 1, hzA ]).fetch() const expected = [ 1, [ { id: 1 }, { id: 2 } ] ] return hzA.insert([ { id: 1 }, { id: 2 }, ]).concat(observableInterleave({ query, operations: [], equality: arrayHasSameElements, expected: [ expected ], })) })) it('allows a fully constant aggregate of primitives', assertCompletes(() => { const agg = { a: 'Some string', b: [ true ], c: new Date(), d: { e: new ArrayBuffer(), f: 1.2, g: [ 1.3, true, new Date(), {} ], }, } return observableInterleave({ query: horizon.aggregate(agg).fetch(), operations: [], equality: assert.deepEqual, expected: [ agg ], }) })) it('aggregates data from objects', assertCompletes(() => { const hzAContents = [ { id: 1, a: true }, { id: 2, b: false }, { id: 3, c: true }, { id: 4, d: true }, ] const hzBContents = [ { id: 5, e: 'E' }, { id: 6, f: 'F' }, { id: 7, g: 'G' }, { id: 8, h: 'H' }, ] const query = horizon.aggregate({ item1: hzA.find(1), item2: hzB.above({ id: 5 }).below({ id: 8 }), }).fetch() const expectedResult = { item1: { id: 1, a: true }, item2: [ { id: 5, e: 'E' }, { id: 6, f: 'F' }, { id: 7, g: 'G' }, ], } return hzA.insert(hzAContents).concat(hzB.insert(hzBContents)) .concat(observableInterleave({ query, operations: [], equality: assert.deepEqual, expected: [ expectedResult ], })) })) it('allows observables in aggregates', assertCompletes(() => { const hzAContents = [ { id: 1, foo: true }, ] const constantObservable = Rx.Observable.of({ id: 2, foo: false }) assert.instanceOf(constantObservable, Rx.Observable) const regularConstant = { id: 3, foo: true } const expectedResult = { a: { id: 1, foo: true }, b: { id: 2, foo: false }, c: { id: 3, foo: true }, d: { id: 4, foo: false }, } return hzA.insert(hzAContents) .concat(observableInterleave({ query: horizon.aggregate({ a: hzA.find(1), b: constantObservable, c: regularConstant, d: Promise.resolve({ id: 4, foo: false }), }).fetch(), operations: [], equality: assert.deepEqual, expected: [ expectedResult ], })) })) it('allows nested aggregates with queries at different levels', assertCompletes(() => { const hzAContents = [ { id: 1, contents: 'a' }, { id: 2, contents: 'b' }, { id: 3, contents: 'c' }, ] const hzBContents = [ { id: 4, contents: 'd' }, { id: 5, contents: 'e' }, { id: 6, contents: 'f' }, ] const query = horizon.aggregate({ a: hzA.find(1), b: { c: hzB.find(4), d: hzB.find(5), e: { f: [ hzA.find(2), hzA.find(3) ], }, }, }).fetch() const expectedResult = { a: { id: 1, contents: 'a' }, b: { c: { id: 4, contents: 'd' }, d: { id: 5, contents: 'e' }, e: { f: [ { id: 2, contents: 'b' }, { id: 3, contents: 'c' } ], }, }, } return hzA.insert(hzAContents) .concat(hzB.insert(hzBContents)) .concat(observableInterleave({ query, operations: [], equality: assert.deepEqual, expected: [ expectedResult ], })) } )) it('can be parameterized with .model', assertCompletes(() => { const hzAContents = [ { id: 1, contents: 'a' }, { id: 2, contents: 'b' }, { id: 3, contents: 'c' }, ] const hzBContents = [ { id: 1, contents: 'd' }, { id: 2, contents: 'e' }, { id: 3, contents: 'f' }, ] const Model = horizon.model((foo, bar, baz) => ({ a: hzA.find(foo), b: { c: hzB.find(foo), d: hzB.find(bar), e: { f: [ hzA.find(bar), hzA.find(baz) ], }, }, })) const expectedResult = { a: { id: 1, contents: 'a' }, b: { c: { id: 1, contents: 'd' }, d: { id: 2, contents: 'e' }, e: { f: [ { id: 2, contents: 'b' }, { id: 3, contents: 'c' } ], }, }, } return hzA.insert(hzAContents) .concat(hzB.insert(hzBContents)) .concat(observableInterleave({ query: Model(1, 2, 3).fetch(), operations: [], equality: assert.deepEqual, expected: [ expectedResult ], })) })) }} ================================================ FILE: client/test/aggregateSub.js ================================================ import { Observable } from 'rxjs/Observable' import { assertCompletes, removeAllDataObs, observableInterleave } from './utils' export default function aggregateSubSuite(getData, getHorizon) { return () => { let data, horizon, hzA, hzB before(() => { data = getData() horizon = getHorizon() hzA = horizon('testA') hzB = horizon('testB') }) afterEach(done => { Observable.merge( removeAllDataObs(hzA), removeAllDataObs(hzB), removeAllDataObs(data)).subscribe({ next() { }, error(err) { done(err) }, complete() { done() }, }) }) it('is equivalent to a subquery if it is not passed an object', assertCompletes(() => { const underlyingQuery = data.order('id').limit(3) return data.insert([ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, ]).concat(observableInterleave({ query: horizon.aggregate(underlyingQuery).watch(), operations: [], expected: [ [ { id: 1 }, { id: 2 }, { id: 3 } ], ], })) }) ) it('combines multiple queries in an array into one', done => { const query = horizon.aggregate([ hzA, hzB ]) const expected = [ [ { id: 1 }, { id: 3 } ], [ { id: 2 }, { id: 4 } ], ] return hzA.insert([ { id: 1 }, { id: 3 }, ]).ignoreElements().concat(hzB.insert([ { id: 2 }, { id: 4 }, ])).ignoreElements().concat(query.watch()) .take(1) .subscribe({ next(x) { for (let i = 0; i < x.length; i++) { assert.sameDeepMembers(expected[i], x[i]) } }, error(err) { done(new Error(err)) }, complete() { done() }, }) }) it('allows constants in an array spec', assertCompletes(() => { const query = horizon.aggregate([ 1, hzA ]) const expected = [ 1, [ { id: 1 }, { id: 2 } ] ] return hzA.insert([ { id: 1 }, { id: 2 }, ]).ignoreElements() .concat(query.watch()) .take(1) .do(x => { assert.equal(x[0], 1) assert.sameDeepMembers(x[1], expected[1]) }) })) it('allows a fully constant aggregate of primitives', assertCompletes(() => { const aggregate = { a: 'Some string', b: [ true ], c: new Date(), d: { e: new ArrayBuffer(), f: 1.2, g: [ 1.3, true, new Date(), { } ], }, } const query = horizon.aggregate(aggregate).watch() return observableInterleave({ query, operations: [], equality: assert.deepEqual, expected: [ aggregate ], }) })) it('aggregates data from objects', assertCompletes(() => { const hzAContents = [ { id: 1, a: true }, { id: 2, b: false }, { id: 3, c: true }, { id: 4, d: true }, ] const hzBContents = [ { id: 5, e: 'E' }, { id: 6, f: 'F' }, { id: 7, g: 'G' }, { id: 8, h: 'H' }, ] const query = horizon.aggregate({ item1: hzA.find(1), item2: hzB.above({ id: 5 }).below({ id: 8 }), }).watch() const expectedResult = { item1: { id: 1, a: true }, item2: [ { id: 5, e: 'E' }, { id: 6, f: 'F' }, { id: 7, g: 'G' }, ], } return hzA.insert(hzAContents).concat(hzB.insert(hzBContents)) .concat(observableInterleave({ query, operations: [], equality: assert.deepEqual, expected: [ expectedResult ], })) })) it('allows observables in aggregates', assertCompletes(() => { const hzAContents = [ { id: 1, foo: true }, ] const constantObservable = Observable.of({ id: 2, foo: false }) assert.instanceOf(constantObservable, Observable) const regularConstant = { id: 3, foo: true } const expectedResult = { a: { id: 1, foo: true }, b: { id: 2, foo: false }, c: { id: 3, foo: true }, d: { id: 4, foo: false }, } return hzA.insert(hzAContents) .concat(observableInterleave({ query: horizon.aggregate({ a: hzA.find(1), b: constantObservable, c: regularConstant, d: Promise.resolve({ id: 4, foo: false }), }).watch(), operations: [], equality: assert.deepEqual, expected: [ expectedResult ], })) })) it('allows nested aggregates with queries at different levels', assertCompletes(() => { const hzAContents = [ { id: 1, contents: 'a' }, { id: 2, contents: 'b' }, { id: 3, contents: 'c' }, ] const hzBContents = [ { id: 4, contents: 'd' }, { id: 5, contents: 'e' }, { id: 6, contents: 'f' }, ] const query = horizon.aggregate({ a: hzA.find(1), b: { c: hzB.find(4), d: hzB.find(5), e: { f: [ hzA.find(2), hzA.find(3) ], }, }, }).watch() const expectedResult = { a: { id: 1, contents: 'a' }, b: { c: { id: 4, contents: 'd' }, d: { id: 5, contents: 'e' }, e: { f: [ { id: 2, contents: 'b' }, { id: 3, contents: 'c' } ], }, }, } return hzA.insert(hzAContents) .concat(hzB.insert(hzBContents)) .concat(observableInterleave({ query, operations: [], equality: assert.deepEqual, expected: [ expectedResult ], })) })) it('can be parameterized with .model', assertCompletes(() => { const hzAContents = [ { id: 1, contents: 'a' }, { id: 2, contents: 'b' }, { id: 3, contents: 'c' }, ] const hzBContents = [ { id: 1, contents: 'd' }, { id: 2, contents: 'e' }, { id: 3, contents: 'f' }, ] const Model = horizon.model((foo, bar, baz) => ({ a: hzA.find(foo), b: { c: hzB.find(foo), d: hzB.find(bar), e: { f: [ hzA.find(bar), hzA.find(baz) ], }, }, })) const expectedResultA = { a: { id: 1, contents: 'a' }, b: { c: { id: 1, contents: 'd' }, d: { id: 2, contents: 'e' }, e: { f: [ { id: 2, contents: 'b' }, { id: 3, contents: 'c' } ], }, }, } const expectedResultB = { a: { id: 1, contents: 'a' }, b: { c: { id: 1, contents: 'something' }, d: { id: 2, contents: 'e' }, e: { f: [ { id: 2, contents: 'b' }, { id: 3, contents: 'c' } ], }, }, } return hzA.insert(hzAContents) .concat(hzB.insert(hzBContents)) .concat(observableInterleave({ query: Model(1, 2, 3).watch(), operations: [ hzB.upsert({ id: 1, contents: 'something' }), ], equality: assert.deepEqual, expected: [ expectedResultA, expectedResultB ], })) })) }} ================================================ FILE: client/test/api.js ================================================ import 'rxjs/add/operator/ignoreElements' import 'rxjs/add/operator/concat' import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, removeAllData, compareSetsWithoutVersion } from './utils' import horizonObjectSuite from './horizonObject' import storeSuite from './store' import insertSuite from './insert' import updateSuite from './update' import upsertSuite from './upsert' import replaceSuite from './replace' import removeSuite from './remove' import removeAllSuite from './removeAll' import timesSuite from './times' import authSuite from './auth' import collectionSuite from './collection' import findSuite from './find' import findAllSuite from './findAll' import orderSuite from './order' import limitSuite from './limit' import aboveSuite from './above' import belowSuite from './below' import chainingSuite from './chaining' import findSubscriptionSuite from './findSub' import findAllSubscriptionSuite from './findAllSub' import aboveSubscriptionSuite from './aboveSub' import belowSubscriptionSuite from './belowSub' import orderLimitSubSuite from './orderLimitSub' import aggregateSuite from './aggregate' import aggregateSubSuite from './aggregateSub' import unitUtilsSuite from './unit/utilsTest' import unitAuthSuite from './unit/auth' import unitAstSuite from './unit/ast' // This test suite covers various edge cases in the Horizon client library API. // It does not cover correctness of the full system in various circumstances. // The purpose of the API test suite is to act as a runnable, checkable spec for // API of the client library. This also doesn't cover subscriptions, there is a // separate test suite for that. // Test the methods and event callbacks on the Horizon object. describe('Horizon Object API', horizonObjectSuite) // Test the core client library API describe('Core API tests', () => { // The connection for our tests let horizon, data const getHorizon = () => horizon const getData = () => data // Set up the horizon connection before running these tests. before(done => { Horizon.clearAuthTokens() horizon = Horizon({ lazyWrites: true }) horizon.connect(err => done(err)) horizon.onReady(() => { data = horizon('test_data') done() }) }) // Kill the horizon connection after running these tests. after(done => { let alreadyDone = false function wrappedDone(...args) { if (!alreadyDone) { alreadyDone = true return done(...args) } } horizon.disconnect() horizon.onDisconnected(() => wrappedDone()) }) // Test the mutation commands describe('Write API', () => { // Drop all data after each test afterEach(done => removeAllData(data, done)) describe('Testing `store`', storeSuite(getData)) describe('Testing `insert`', insertSuite(getData)) describe('Testing `upsert`', upsertSuite(getData)) describe('Testing `update`', updateSuite(getData)) describe('Testing `replace`', replaceSuite(getData)) }) // Storage API describe('Remove API', () => { describe('Testing `remove`', removeSuite(getData)) describe('Testing `removeAll`', removeAllSuite(getData)) }) describe('Testing `times`', timesSuite(getData)) describe('Testing authentication', authSuite(getHorizon)) // Test the lookup API describe('Fetch API', () => { const testData = [ { id: 1, a: 10 }, { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, { id: 4, a: 20, b: 3 }, { id: 5, a: 60 }, { id: 6, a: 50 }, ] const getTestData = () => { return testData } // Drop all the existing data before(done => { removeAllData(data, done) }) // Insert the test data and make sure it's in before(assertCompletes(() => data.store(testData) .ignoreElements() .concat(data.fetch()) .do(res => compareSetsWithoutVersion(res, testData)) )) describe('Testing full collection read', collectionSuite(getHorizon, getData, getTestData)) describe('Testing `find`', findSuite(getData)) describe('Testing `findAll`', findAllSuite(getData)) describe('Testing `order`', orderSuite(getData, getTestData)) describe('Testing `limit`', limitSuite(getData)) describe('Testing `above`', aboveSuite(getData)) describe('Testing `below`', belowSuite(getData)) describe('Test `above/below/limit` chaining variations', chainingSuite(getData)) }) // Test the lookup API // Test the subscriptions API describe('Watch API', () => { // Drop all the existing data beforeEach(done => { removeAllData(data, done) }) describe('Testing `find` subscriptions', findSubscriptionSuite(getData)) describe('Testing `findAll` subscriptions', findAllSubscriptionSuite(getData)) describe('Testing `above` subscriptions', aboveSubscriptionSuite(getData)) describe('Testing `below` subscriptions', belowSubscriptionSuite(getData)) describe('Testing `order.limit` subscriptions', orderLimitSubSuite(getData)) }) // Test the subscriptions API describe('Serialization', () => { describe('Testing `times`', timesSuite(getData)) }) describe('Aggregate API', () => { describe('fetch', aggregateSuite(getData, getHorizon)) describe('watch', aggregateSubSuite(getData, getHorizon)) }) describe('Unit tests', () => { describe('Auth', unitAuthSuite) describe('Utils', unitUtilsSuite) describe('AST', unitAstSuite) }) }) // Core API tests ================================================ FILE: client/test/auth.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMap' import 'rxjs/add/operator/mergeMapTo' export default function authSuite(getHorizon) { return () => { let horizon before(() => { horizon = getHorizon() }) it('gets an error when unauthenticated', done => { const unauthHorizon = Horizon({ secure: false, lazyWrites: true, authType: 'unauthenticated', }) unauthHorizon.currentUser().fetch().subscribe({ next(user) { throw new Error('Expected an error, got a document') }, error(err) { assert.equal(err.message, 'Unauthenticated users have no user document') done() }, complete() { throw new Error('Expected an error, completed successfully instead') }, }) }) it('gets a normal user object when anonymous', done => { Horizon.clearAuthTokens() const myHorizon = Horizon({ secure: false, lazyWrites: true, authType: 'anonymous', }) let asserted = 0 myHorizon.currentUser().fetch().subscribe({ next(user) { assert.isObject(user) assert.isString(user.id) assert.sameDeepMembers(user.groups, [ 'default', 'authenticated' ]) asserted += 1 }, error(e) { done(e) }, complete() { if (asserted < 1) { done(new Error('Completed before receiving a document')) } else if (asserted > 1) { done(new Error('Received too many documents before completing')) } else { done() } }, }) }) it('write to the user object', done => { Horizon.clearAuthTokens() const myHorizon = Horizon({ secure: false, lazyWrites: true, authType: 'anonymous' }) const new_groups = [ 'admin', 'superuser', 'default' ]; let asserted = 0 myHorizon.currentUser().fetch() .mergeMap(user => myHorizon('users') .update({ id: user.id, groups: [ 'admin', 'superuser', 'default' ] })) .mergeMapTo(myHorizon.currentUser().fetch()).subscribe({ next(user) { assert.isObject(user) assert.isString(user.id) assert.sameDeepMembers(user.groups, new_groups) asserted += 1 }, error(e) { done(e) }, complete() { if (asserted < 1) { done(new Error('Completed before receiving a document')) } else if (asserted > 1) { done(new Error('Received too many documents before completing')) } else { done() } }, }) }) }} ================================================ FILE: client/test/below.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion } from './utils' export default function belowSuite(getData) { return () => { let data before(() => { data = getData() }) // By default `below` is open it('defaults to open', assertCompletes(() => data.order('id').below({ id: 3 }).fetch() .do(res => compareWithoutVersion(res, [ { id: 1, a: 10 }, { id: 2, a: 20, b: 1 } ])) )) // We can also pass that explicitly it('can be explicitly set to be an open bound', assertCompletes(() => data.order('id').below({ id: 3 }, 'open').fetch() .do(res => compareWithoutVersion(res, [ { id: 1, a: 10 }, { id: 2, a: 20, b: 1 }, ])) )) // But we can make it closed it('can be explicitly set to be a closed bound', assertCompletes(() => data.order('id').below({ id: 3 }, 'closed').fetch() .do(res => compareWithoutVersion(res, [ { id: 1, a: 10 }, { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, ])) )) // Let's try something that returns no values it('can return no values', assertCompletes(() => data.order('id').below({ id: 0 }).fetch() .do(res => compareWithoutVersion(res, [])) )) // We can chain `below` off a collection it('can be chained off of a collection', assertCompletes(() => data.below({ id: 3 }).fetch() .do(res => { assert.isArray(res) assert.lengthOf(res, 2) }) )) // Or off other things it('can be chained off of a findAll term', assertCompletes(() => data.findAll({ a: 20 }).below({ id: 4 }).fetch() .do(res => { assert.isArray(res) assert.lengthOf(res, 2) }) )) // `below` can't include any keys that are in `findAll` it('cannot include any keys that are passed to findAll', assertErrors(() => data.findAll({ a: 20 }).below({ a: 3 }).fetch(), /"a" cannot be used in "order", "above", or "below" when finding by that field/ )) // Let's try it on a non-primary index it('can bound a non-primary index', assertCompletes(() => data.order([ 'a', 'id' ]).below({ a: 20 }).fetch() .do(([ res ]) => compareWithoutVersion(res, { id: 1, a: 10 })) )) // Let's try it on a non-primary key, but closed it('can closed bound a non-primary key', assertCompletes(() => data.order([ 'a', 'id' ]).below({ a: 20 }, 'closed').fetch() .do(res => compareWithoutVersion(res, [ { id: 1, a: 10 }, { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, { id: 4, a: 20, b: 3 }, ])) )) // The key in `below` must be the first key in `order` it('must receive as an argument the first key in the order term', assertErrors(() => data.order(['a', 'id']).below({ id: 20 }).fetch(), /"below" must be on the same field as the first in "order"/ )) // Passing multiple keys to `below` isn't legal it('errors if it receives multiple keys', assertErrors(() => data.order(['a', 'id']).below({ a: 20, id: 20 }).fetch(), /"find" is required/ )) // Nor is passing a field that isn't specified in `order` it(`errors if it receives a field that wasn't passed to the order term`, assertErrors(() => data.order(['a', 'id']).below({ b: 20 }).fetch(), /"below" must be on the same field as the first in "order"/ )) // If chaining `below/above`, they must be passed the same key it('must be passed the same key as the above term', assertErrors(() => data.below({ a: 100 }).above({ b: 0 }).fetch(), /"below" must be on the same field as the first in "order"/ )) // Starting with `null` is not ok it('throws if passed null', assertThrows( 'The 1st argument to below must be non-null', () => data.below(null).fetch() )) // Empty value is not ok it('throws if not given an argument', assertThrows( 'below must receive at least 1 argument.', () => data.below().fetch() )) // Bad arguments are not ok it('errors if passed a non-string', assertErrors(() => data.below(1).fetch(), /"find" is required/ )) it('errors if it receives a bound other than open or closed', assertErrors(() => data.below({ id: 1 }, 1).fetch(), /"find" is required/ )) }} ================================================ FILE: client/test/belowSub.js ================================================ import 'rxjs/add/operator/concat' import { assertCompletes, observableInterleave } from './utils' export default function belowSubscriptionSuite(getData) { return () => { let data before(() => { data = getData() }) // Let's grab a specific document using 'below' it('can grab a specific document', assertCompletes(() => observableInterleave({ query: data.below({ id: 2 }).watch(), operations: [ data.store({ id: 1, a: 1 }), data.remove(1), ], expected: [ [], [ { id: 1, a: 1 } ], [], ], }) )) // Let's grab a specific document using 'below' and also test the 'changed' // event. it('properly handles changes to documents in its range', assertCompletes(() => observableInterleave({ query: data.below({ id: 2 }).watch(), operations: [ data.store({ id: 1, a: 1 }), data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ [], [ { id: 1, a: 1 } ], [ { id: 1, a: 2 } ], [], ] }) )) // Secondary index, closed it('can find documents by secondary index with closed bound', assertCompletes(() => observableInterleave({ query: data.below({ a: 2 }, 'closed').watch(), operations: [ data.store({ id: 1, a: 1 }), data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ [], [ { id: 1, a: 1 } ], [ { id: 1, a: 2 } ], [], ] }) )) // Let's make sure we don't see events that aren't ours it("doesn't see updates to documents outside its range", assertCompletes(() => observableInterleave({ query: data.below({ id: 1 }).watch(), operations: [ data.store({ id: 2, a: 1 }) .concat(data.store({ id: 2, a: 2 })) .concat(data.store({ id: 0, val: 'foo' })), data.remove(2) .concat(data.remove(0)), ], expected: [ [], [ { id: 0, val: 'foo' } ], [], ], }) )) // Let's try subscribing to multiple IDs it('can subscribe to multiple ids', assertCompletes(() => observableInterleave({ query: data.below({ id: 3 }).watch(), operations: [ data.store({ id: 1, a: 1 }), data.store({ id: 2, a: 1 }) .concat(data.store({ id: 3, a: 1 })), data.store({ id: 1, a: 2 }), data.store({ id: 2, a: 2 }) .concat(data.store({ id: 3, a: 2 })), data.remove(1), data.remove(2) .concat(data.remove(3)), ], expected: [ [], [ { id: 1, a: 1 } ], [ { id: 1, a: 1 }, { id: 2, a: 1 } ], [ { id: 1, a: 2 }, { id: 2, a: 1 } ], [ { id: 1, a: 2 }, { id: 2, a: 2 } ], [ { id: 2, a: 2 } ], [], ], }) )) // Let's make sure initial vals works correctly it('handles initial values correctly', assertCompletes(() => data.store({ id: 1, a: 1 }).concat( observableInterleave({ query: data.below({ id: 2 }).watch(), operations: [ data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ [ { id: 1, a: 1 } ], [ { id: 1, a: 2 } ], [], ], }) ) )) }} ================================================ FILE: client/test/chaining.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, compareWithoutVersion } from './utils' export default function chainingSuite(getData) { return () => { let data before(() => { data = getData() }) // Let's do a biiig chain it('findAll.order.above.below', assertCompletes(() => data.findAll({ a: 20 }) .order('id') .above({ id: 2 }) .below({ id: 4 }) .fetch() .do(res => compareWithoutVersion(res, [ { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, ])) )) // Let's flip it the other way and change the order it('findAll.below.above.order(desc)', assertCompletes(() => data.findAll({ a: 20 }) .below({ id: 4 }) .above({ id: 2 }) .order('id', 'descending') .fetch() .do(res => compareWithoutVersion(res, [ { id: 3, a: 20, b: 2 }, { id: 2, a: 20, b: 1 }, ])) )) // Let's throw limit into the mix it('findAll.order.above.below.limit', assertCompletes(() => data.findAll({ a: 20 }) .above({ id: 2 }) .order('id').below({ id: 4 }).limit(1) .fetch() .do(res => compareWithoutVersion(res, [ { id: 2, a: 20, b: 1 } ])) )) // Let's do it on the collection it('order.above.below.limit', assertCompletes(() => data.below({ id: 4 }) .order('id') .above({ id: 2 }) .limit(1) .fetch() .do(res => compareWithoutVersion(res, [ { id: 2, a: 20, b: 1 } ])) )) // Let's try a big compound example it('findAll.order.above.below.limit', assertCompletes(() => data.findAll({ a: 20 }) .order('id') .above({ id: 2 }) .below({ id: 4 }, 'closed') .limit(2) .fetch() .do(res => compareWithoutVersion(res, [ { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, ])) )) // Can't chain off vararg `findAll` it('throws if order is chained off a multi-arg findAll', assertThrows( 'order cannot be called on the current query', () => data.findAll({ a: 20 }, { a: 50 }).order('id').fetch() )) }} ================================================ FILE: client/test/collection.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, removeAllData, compareSetsWithoutVersion } from './utils' export default function collectionSuite(getHorizon, getData, getTestData) { return () => { let horizon, data, testData, empty_collection before(() => { horizon = getHorizon() data = getData() testData = getTestData() }) // We'll need a separate empty collection before(done => { empty_collection = horizon('empty_test_collection') removeAllData(empty_collection, done) }) // Grab everything from the collection. it('allows getting all values from the collection', assertCompletes(() => data.fetch() .do(res => compareSetsWithoutVersion(testData, res)) )) // Reading from an empty collection should result in an empty array it('returns an empty array from an empty collection', assertCompletes(() => empty_collection.fetch() .do(res => compareSetsWithoutVersion(res, [])) )) // Test forEach for promise behavior it('Allows iterating over the entire collection', done => { let didSomething = false data.fetch().forEach(results => { didSomething = true }).then(() => { if (didSomething) { done() } else { done(new Error("Didn't do anything")) } }).catch(err => done(err)) }) }} ================================================ FILE: client/test/find.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion } from './utils' export default function findSuite(getData) { return () => { let data before(() => { data = getData() }) // Let's grab a specific document using `find` it('locates a single document when passed an id', assertCompletes(() => data.find(1).fetch() .do(res => compareWithoutVersion(res, { id: 1, a: 10 })) )) // This is equivalent to searching by field `id` it('locates a single document when passed an object with an id field', assertCompletes(() => data.find({ id: 1 }).fetch() .do(res => compareWithoutVersion(res, { id: 1, a: 10 })) )) // `find` returns `null` if a document doesn't exist. it(`returns null if an object doesn't exist`, assertCompletes(() => data.find('abracadabra').fetch() .do(res => assert.equal(res, null)) )) // Looking for `null` is an error. RethinkDB doesn't allow secondary // index values to be `null`. it('throws an error if called with null', assertThrows( 'The argument to find must be non-null', () => data.find(null).fetch() )) // Looking for `undefined` is also an error. it('throws an error if called with undefined', assertThrows( 'The 1st argument to find must be defined', () => data.find(undefined).fetch() )) it('throws an error if no arguments are passed', assertThrows( 'find must receive exactly 1 argument', () => data.find().fetch() )) // The document passed to `find` can't be empty it('errors if the document passed is empty', assertErrors(() => data.find({}).fetch(), /must have at least 1 children/ )) // We can also `find` by a different (indexed!) field. In that case, // `find` will return the first match. it('locates documents by other fields if passed an object', assertCompletes(() => data.find({ a: 10 }).fetch() .do(res => compareWithoutVersion(res, { id: 1, a: 10 })) )) // Let's try this again for a value that doesn't exist. it('returns null if a document with the given value doesnt exist', assertCompletes(() => data.find({ a: 100 }).fetch() .do(res => assert.equal(res, null)) )) // Let's try this again for a field that doesn't exist. it('returns null if no object with the given field exists', assertCompletes(() => data.find({ field: 'a' }).fetch() .do(res => assert.equal(res, null)) )) // Let's try this again, now with multiple results. it('returns one result even if several documents match', assertCompletes(() => data.find({ a: 20 }).fetch() // The id should be one of 2, 3, or 4 .do(res => { assert.include([ 2, 3, 4 ], res.id) }) )) // Users can pass multiple fields to look for it('can find documents when constrained by multiple field values', assertCompletes(() => data.find({ a: 20, b: 1 }).fetch() .do(res => compareWithoutVersion(res, { id: 2, a: 20, b: 1 })) )) // In this case there is no matching document it(`wont return anything if documents dont match`, assertCompletes(() => data.find({ a: 20, c: 100 }).fetch() .do(res => assert.equal(res, null)) )) // Passing multiple arguments to find should return a nice error it('throws an error if multiple arguments are passed', assertThrows( 'find must receive exactly 1 argument', () => data.find(1, { id: 1 }).fetch() )) it('emits null when the document id is not found', done => { let gotResult = false return data.find('does_not_exist').fetch().subscribe({ next(result) { gotResult = true assert.deepEqual(result, null) }, error(err) { done(err) }, complete() { if (!gotResult) { done(new Error('never received result')) } else { done() } }, }) }) }} ================================================ FILE: client/test/findAll.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareSetsWithoutVersion } from './utils' export default function findAllSuite(getData) { return () => { let data before(() => { data = getData() }) // Let's grab a specific document using `findAll` it('looks up documents by id when given a non-object', assertCompletes(() => data.findAll(1).fetch() .do(res => compareSetsWithoutVersion(res, [ { id: 1, a: 10 } ])) )) // This is equivalent to searching by field `id` it('looks up documents when the id field is given explicitly', assertCompletes(() => data.findAll({ id: 1 }).fetch() .do(res => compareSetsWithoutVersion(res, [ { id: 1, a: 10 } ])) )) // `findAll` returns `[]` if a document doesn't exist. it('returns nothing if no documents match', assertCompletes(() => data.findAll('abracadabra').fetch() .do(res => compareSetsWithoutVersion(res, [])) )) // We can also `findAll` by a different (indexed!) field. it('returns objects matching non-primary fields', assertCompletes(() => data.findAll({ a: 10 }).fetch() .do(res => compareSetsWithoutVersion(res, [{ id: 1, a: 10 } ])) )) // Let's try this again for a value that doesn't exist. it('returns nothing if no documents match the criteria', assertCompletes(() => data.findAll({ a: 100 }).fetch() .do(res => compareSetsWithoutVersion(res, [])) )) // Let's try this again for a field that doesn't exist. it(`returns nothing if the field provided doesn't exist`, assertCompletes(() => data.findAll({ field: 'a' }).fetch() .do(res => compareSetsWithoutVersion(res, [])) )) // Let's try this again, now with multiple results. it('returns multiple values when several documents match', assertCompletes(() => data.findAll({ a: 20 }).fetch() // There are three docs where `a == 20` .do(res => compareSetsWithoutVersion(res, [ { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, { id: 4, a: 20, b: 3 }, ])) )) // Looking for `null` is an error since secondary index values cannot be // `null` in RethinkDB. it('throws an error when null is passed', assertThrows( 'The 1st argument to findAll must be non-null', () => data.findAll(null).fetch() )) // No args is ok, because people will be using `apply` it('throws an error when passed no arguments', assertThrows( 'findAll must receive at least 1 argument.', () => data.findAll().fetch() )) // Looking for an empty object is also an error it('errors when an empty object is passed', assertErrors(() => data.findAll({}).fetch(), /"find" is required/ )) // `findAll` lets us look for multiple documents. Let's try it on a primary // key. it('can be passed multiple documents to look for', assertCompletes(() => data.findAll(1, { id: 2 }, 20).fetch() // There are two docs where `a == 20` .do(res => compareSetsWithoutVersion(res, [ { id: 1, a: 10 }, { id: 2, a: 20, b: 1 }, ])) )) // Let's try a mix of primary and secondary keys, with some missing it('can locate a mix of primary and secondary keys', assertCompletes(() => data.findAll({ a: 20 }, { id: 200 }, 1, { a: 200 }).fetch() // There are three docs where `a == 20` .do(res => compareSetsWithoutVersion(res, [ { id: 1, a: 10 }, { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, { id: 4, a: 20, b: 3 }, ])) )) // Let's try when everything is missing it('returns nothing when nothing matches', assertCompletes(() => data.findAll({ field: 1 }, 200, { a: 200 }).fetch() .do(val => compareSetsWithoutVersion(val, [])) )) // When one thing fails, everything fails. it('throws an error if any argument is null', assertThrows( 'The 2nd argument to findAll must be non-null', () => data.findAll(1, null, 2).fetch() )) // Let's try it again with an empty object. it('errors if any argument passed is an empty object', assertErrors(() => data.findAll(1, {}, { a: 20 }).fetch(), /"find" is required/ )) }} ================================================ FILE: client/test/findAllSub.js ================================================ import 'rxjs/add/operator/concat' import { assertCompletes, observableInterleave } from './utils' export default function findAllSubscriptionSuite(getData) { return () => { let data before(() => { data = getData() }) // Let's grab a specific document using 'findAll' it('can find a single document', assertCompletes(() => observableInterleave({ query: data.findAll(1).watch(), operations: [ data.store({ id: 1, a: 1 }), data.remove(1), ], expected: [ [], [ { id: 1, a: 1 } ], [], ], }) )) // Let's grab a specific document using 'findAll' and also test the 'changed' // event. it('can find a document and reflect changes', assertCompletes(() => observableInterleave({ query: data.findAll(1).watch(), operations: [ data.store({ id: 1, a: 1 }), data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ [], [ { id: 1, a: 1 } ], [ { id: 1, a: 2 } ], [], ], }) )) // Let's make sure we don't see events that aren't ours it("doesn't see changes to documents outside its range", assertCompletes(() => observableInterleave({ query: data.findAll(1).watch(), operations: [ data.store({ id: 2, a: 1 }) .concat(data.store({ id: 2, a: 2 })) .concat(data.remove(2)), ], expected: [ [], ], }) )) // Let's try subscribing to multiple IDs it('can subscribe to multiple ids', assertCompletes(() => observableInterleave({ query: data.findAll(1, 2).watch(), operations: [ data.store({ id: 1, a: 1 }), data.store({ id: 2, a: 1 }) .concat(data.store({ id: 3, a: 1 })), data.store({ id: 1, a: 2 }), data.store({ id: 2, a: 2 }) .concat(data.store({ id: 3, a: 2 })), data.remove(1), data.remove(2) .concat(data.remove(3)), ], expected: [ [], [ { id: 1, a: 1 } ], [ { id: 1, a: 1 }, { id: 2, a: 1 } ], [ { id: 1, a: 2 }, { id: 2, a: 1 } ], [ { id: 1, a: 2 }, { id: 2, a: 2 } ], [ { id: 2, a: 2 } ], [], ], }) )) // Let's make sure initial vals works correctly it('properly handles initial values', assertCompletes(() => data.store([ { id: 1, a: 1 }, { id: 2, b: 1 } ]).concat( observableInterleave({ query: data.findAll(1, 2).watch(), operations: [ data.store({ id: 1, a: 2 }), data.remove(2), data.remove(1), ], expected: [ [ { id: 1, a: 1 }, { id: 2, b: 1 } ], [ { id: 1, a: 2 }, { id: 2, b: 1 } ], [ { id: 1, a: 2 } ], [], ], }) ) )) }} ================================================ FILE: client/test/findSub.js ================================================ import 'rxjs/add/operator/concat' import { assertCompletes, observableInterleave } from './utils' export default function findSubscriptionSuite(getData) { return () => { let data before(() => { data = getData() }) it('returns an updating document', assertCompletes(() => observableInterleave({ query: data.find(1).watch(), operations: [ data.insert({ id: 1, val: 'foo' }), data.replace({ id: 1, val: 'bar' }), data.remove({ id: 1 }), ], expected: [ null, { id: 1, val: 'foo' }, { id: 1, val: 'bar' }, null, ], }) )) // Let's grab a specific document using `find` it('receives results from store operations', assertCompletes(() => observableInterleave({ query: data.find(1).watch(), operations: [ data.store({ id: 1, a: 1 }), data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ null, { id: 1, a: 1 }, { id: 1, a: 2 }, null, ], }) )) // Let's make sure we don't see events that aren't ours it("doesn't see events that don't belong to it", assertCompletes(() => observableInterleave({ query: data.find(1).watch(), operations: [ // only one operation data.store({ id: 2, a: 1 }) // irrelevant .concat(data.store({ id: 2, a: 2 })) // irrelevant .concat(data.insert({ id: 1, data: 'blep' })) // relevant .concat(data.remove(2)), // removing irrelevant data.remove(1), // triggered after relevant only ], expected: [ null, { id: 1, data: 'blep' }, null, ], }) )) // Let's make sure initial vals works correctly it('properly handles initial values', assertCompletes(() => // before starting the feed, insert initial document data.store({ id: 1, a: 1 }) .concat(observableInterleave({ query: data.find(1).watch(), operations: [ data.store({ id: 1, a: 2 }), data.remove(1), ], expected: [ { id: 1, a: 1 }, { id: 1, a: 2 }, null, ], }) ) )) it('emits null when the document id is not found', assertCompletes(() => { return observableInterleave({ query: data.find('does_not_exist').watch(), operations: [], expected: [ null ], }) })) }} ================================================ FILE: client/test/horizonObject.js ================================================ 'use strict' // Test object creation, the `disconnect` method, and `connected/disconnected` // events. function doneWrap(done) { let alreadyDone = false return (...args) => { if (!alreadyDone) { alreadyDone = true return done(...args) } } } export default function horizonObjectSuite() { describe('Horizon', () => { it('connects and can track its status', done => { let oneDone = doneWrap(done) Horizon.clearAuthTokens() const horizon = Horizon({ secure: false }) assert.isDefined(horizon) horizon.status( stat => { switch (stat.type) { case 'unconnected': break case 'ready': horizon.disconnect() break case 'error': oneDone(new Error('Got an error in socket status')) break case 'disconnected': oneDone() break default: oneDone(new Error(`Received unknown status type ${stat.type}`)) } }, () => oneDone(new Error('Got an error in status')) ) horizon.connect(err => oneDone(err)) }) it('errors when it gets the wrong host', done => { // Note -- the connection string specifies a bad host. const horizon = Horizon({ host: 'wrong_host', secure: false, }) assert.isDefined(horizon) horizon.status().take(3).toArray().subscribe(statuses => { const expected = [ { type: 'unconnected' }, { type: 'error' }, // socket { type: 'disconnected' }, ] assert.deepEqual(expected, statuses) done() }) horizon.connect() // no-op error handler, already covered }) }) } ================================================ FILE: client/test/insert.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMapTo' import 'rxjs/add/operator/mergeMap' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion, compareSetsWithoutVersion } from './utils' export default function insertSuite(getData) { return () => { let data before(() => { data = getData() }) // The `insert` command stores documents in the database, and errors if // the documents already exist. it('stores documents in db, errors if documents already exist', assertErrors(() => data.insert({ id: 1, a: 1, b: 1 }).toArray() // Should return an array with an ID of the inserted // document. .do(res => compareWithoutVersion([ { id: 1 } ], res)) // Let's make sure we get back the document that we put in. .mergeMapTo(data.find(1).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion({ id: 1, a: 1, b: 1 }, res)) // Let's attempt to overwrite the document now. This should error. .mergeMapTo(data.insert({ id: 1, c: 1 })), /The document already exists/ )) // If we insert a document without an ID, the ID is generated for us. // Let's run the same test as above (insert the document and then // attempt to overwrite it), but have the ID be generated for us. it(`generates ids if documents don't already have one`, assertErrors(() => { let new_id return data.insert({ a: 1, b: 1 }).toArray() // should return an array with an ID of the inserted document. .do(res => { assert.isArray(res) assert.lengthOf(res, 1) assert.isString(res[0].id) new_id = res[0].id }) // Let's make sure we get back the document that we put in. .mergeMap(() => data.find(new_id).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion({ id: new_id, a: 1, b: 1 }, res)) // Let's attempt to overwrite the document now .mergeMap(() => data.insert({ id: new_id, c: 1 })) }, /The document already exists/ )) it('fails if null is passed', assertThrows( 'The argument to insert must be non-null', () => data.insert(null) )) it('fails if undefined is passed', assertThrows( 'The 1st argument to insert must be defined', () => data.insert(undefined) )) it('fails if given no argument', assertThrows( 'insert must receive exactly 1 argument', () => data.insert() )) // The `insert` command allows storing multiple documents in one call. // Let's insert a few kinds of documents and make sure we get them back. it('can store multiple documents in one call', assertCompletes(() => { let new_id_0, new_id_1 return data.insert([ {}, { a: 1 }, { id: 1, a: 1 }, ]).toArray() .do(res => { // should return an array with the IDs of the documents in // order, including the generated IDS. assert.isArray(res) assert.lengthOf(res, 3) assert.isString(res[0].id) assert.isString(res[1].id) assert.equal(1, res[2].id) new_id_0 = res[0].id new_id_1 = res[1].id }) // Make sure we get what we put in. .mergeMap(() => data.findAll(new_id_0, new_id_1, 1).fetch()) // We're supposed to get an array of documents we put in .do(res => compareSetsWithoutVersion(res, [ { id: new_id_0 }, { id: new_id_1, a: 1 }, { id: 1, a: 1 }, ])) })) // If any operation in a batch insert fails, everything is reported as a // failure. it('gets an Error object if an operation in a batch fails', assertCompletes(() => // Lets insert a document that will trigger a duplicate error when we // attempt to reinsert it data.insert({ id: 2, a: 2 }) // should return an array with an ID of the inserted document. .do(res => compareWithoutVersion(res, { id: 2 })) // Let's make sure we get back the document that we put in. .mergeMap(() => data.find(2).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion(res, { id: 2, a: 2 })) // One of the documents in the batch already exists .mergeMap(() => data.insert([ { id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 3 }, ])) .toArray() .do(results => { assert.equal(results[0].id, 1) assert.instanceOf(results[1], Error) assert.equal(results[2].id, 3) }) )) // Let's trigger a failure in an insert batch again, this time by making // one of the documents `null`. it('fails if any member of batch is null', assertErrors(() => data.insert([ { a: 1 }, null, { id: 1, a: 1 } ]), /must be an object/ )) // Inserting an empty batch of documents is ok, and returns an empty // array. it('can store empty batches', assertCompletes(() => data.insert([]) .do(res => { // should return an array with the IDs of the documents // in order, including the generated IDS. assert.isArray(res) assert.lengthOf(res, 0) }) )) }} ================================================ FILE: client/test/limit.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion } from './utils' export default function limitSuite(getData) { return () => { let data before(() => { data = getData() }) // Limit returns an array of documents it('can return an array of documents', assertCompletes(() => data.order('id').limit(2).fetch() .do(res => compareWithoutVersion(res, [ { id: 1, a: 10 }, { id: 2, a: 20, b: 1 }, ])) )) // We can chain `limit` off a collection it('can be called on a collection directly', assertCompletes(() => data.limit(2).fetch() .do(res => { assert.isArray(res) assert.lengthOf(res, 2) }) )) // Or off other things it('can be called on findAll', assertCompletes(() => data.findAll({ a: 20 }).limit(2).fetch() .do(res => { assert.isArray(res) assert.lengthOf(res, 2) }) )) // `limit(0)` is ok it('can accept an argument of 0', assertCompletes(() => data.limit(0).fetch() .do(res => compareWithoutVersion(res, [])) )) // `limit(null)` is an error it('throws if it receives null', assertThrows( 'The argument to limit must be non-null', () => data.limit(null).fetch() )) // `limit(-1)` is an error it('errors if it receives a negative argument', assertErrors(() => data.limit(-1).fetch(), /"find" is required/ )) // `limit(non_int)` is an error it(`errors if the argument to limit isn't a number`, assertErrors(() => data.limit('k').fetch(), /"find" is required/ )) // Chaining off of limit is illegal it('throws if findAll is called on it', assertThrows( 'findAll cannot be called on the current query', () => data.limit(1).findAll({ id: 1 }).fetch() )) it('throws if below is called on it', assertThrows( 'below cannot be called on the current query', () => data.limit(1).below({ id: 1 }).fetch() )) it('throws if above is called on it', assertThrows( 'above cannot be called on the current query', () => data.limit(1).above({ id: 1 }).fetch() )) it('throws if order is called on it', assertThrows( 'order cannot be called on the current query', () => data.limit(1).order('id').fetch() )) }} ================================================ FILE: client/test/order.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion } from './utils' import cloneDeep from 'lodash.clonedeep' import sortBy from 'lodash.sortby' export default function orderSuite(getData, getTestData) { return () => { let data, testData before(() => { data = getData() testData = getTestData() }) // We can order by a field (default order is ascending) it('orders results by a field', assertCompletes(() => data.order('id').fetch() .do(res => compareWithoutVersion(res, testData)) )) // That's the same as passing `ascending` explicitly it('orders results ascending implicitly', assertCompletes(() => data.order('id', 'ascending').fetch() .do(res => compareWithoutVersion(res, testData)) )) // We can also sort in descending order it('can order results in descending order', assertCompletes(() => data.order('id', 'descending').fetch() .do(res => compareWithoutVersion(res, cloneDeep(testData).reverse())) )) // Let's try ordering by a different field. it('can order results by a field other than id', assertCompletes(() => data.order('b').fetch() .do(res => compareWithoutVersion(res.slice(0, 3), [ { id: 2, a: 20, b: 1 }, { id: 3, a: 20, b: 2 }, { id: 4, a: 20, b: 3 }, ])) )) // Let's try ordering by a different field descneding. it('can order results by another field in descending order', assertCompletes(() => data.order('b', 'descending').fetch() .do(res => compareWithoutVersion(res.slice(0, 3), [ { id: 4, a: 20, b: 3 }, { id: 3, a: 20, b: 2 }, { id: 2, a: 20, b: 1 }, ])) )) // Let's try to order by a missing field it('returns no documents if a bad field is given', assertCompletes(() => data.order('abracadabra').fetch() .do(res => compareWithoutVersion(res, [])) )) // We can pass multiple fields to `order` to disambiguate. it('can order by multiple fields', assertCompletes(() => data.order([ 'a', 'id' ]).fetch() .do(res => compareWithoutVersion(res, sortBy(testData, [ 'a', 'id' ]))) )) // We can pass multiple fields to `order` to disambiguate. Let's do it in // descending order. it('can order by multiple fields descending', assertCompletes(() => data.order([ 'a', 'id' ], 'descending').fetch() .do(res => compareWithoutVersion(res, sortBy(testData, ['a', 'id']).reverse())) )) // `order` cannot accept any keys that are present in `findAll` it('cannot accept any keys that are present in findAll', assertErrors(() => data.findAll({id: 1}).order('id').fetch(), /"id" cannot be used in "order", "above", or "below" when finding by that field/ )) it(`errors if the 2nd argument isn't 'ascending' or 'descending'`, assertErrors(() => data.order('id', 'foo').fetch(), /"find" is required/ )) // Passing no arguments, null, bad arguments, or too many arguments is // an error. it('throws if it receives no arguments', assertThrows( 'order must receive at least 1 argument.', () => data.order().fetch() )) it('throws if it receives a null argument', assertThrows( 'The 1st argument to order must be non-null', () => data.order(null).fetch() )) it('throws if its first argument is null', assertThrows( 'The 1st argument to order must be non-null', () => data.order(null, 'foo').fetch() )) it('throws if it receives more than 2 arguments', assertThrows( 'order accepts at most 2 arguments.', () => data.order('id', 'ascending', 1).fetch() )) }} ================================================ FILE: client/test/orderLimitSub.js ================================================ import 'rxjs/add/operator/concat' import { assertCompletes, observableInterleave } from './utils' export default function orderLimitSubSuite(getData) { return () => { let data before(() => { data = getData() }) it(`can find a single document`, assertCompletes(() => observableInterleave({ query: data.order('score').limit(1).watch(), operations: [ data.store({ id: 1, score: 1 }), data.remove(1), ], expected: [ [], [ { id: 1, score: 1 } ], [], ], }) )) it('will swap out a document that goes out of range', assertCompletes(() => data.store({ id: 1, score: 200 }).concat( observableInterleave({ query: data.order('score').limit(1).watch(), operations: [ data.store({ id: 2, score: 100 }), data.remove(2), data.remove(1), ], expected: [ [ { id: 1, score: 200 } ], [ { id: 2, score: 100 } ], [ { id: 1, score: 200 } ], [], ], }) ) )) it('reflects changes that change sort order', assertCompletes(() => observableInterleave({ query: data.order('score').limit(2).watch(), operations: [ data.store({ id: 1, score: 200 }), data.store({ id: 2, score: 100 }), data.store({ id: 1, score: 50 }), data.remove(1), data.remove(2), ], expected: [ [], [ { id: 1, score: 200 } ], [ { id: 2, score: 100 }, { id: 1, score: 200 } ], [ { id: 1, score: 50 }, { id: 2, score: 100 } ], [ { id: 2, score: 100 } ], [], ], }) )) // Let's make sure we don't see events that aren't ours it("doesn't see changes to documents outside its range", assertCompletes(() => observableInterleave({ query: data.order('score').limit(1).watch(), operations: [ data.store({ id: 1, score: 100 }) .concat(data.store({ id: 2, score: 200 })) .concat(data.remove(2)) .concat(data.remove(1)), ], expected: [ [], [ { id: 1, score: 100 } ], [], ], }) )) // Let's try subscribing to multiple IDs it('respects descending order', assertCompletes(() => observableInterleave({ query: data.order('score', 'descending').limit(3).watch(), operations: [ data.store({ id: 1, score: 10 }), data.store({ id: 2, score: 20 }), data.store({ id: 3, score: 15 }), data.remove(2), data.remove(1), data.remove(3), ], expected: [ [], [ { id: 1, score: 10 } ], [ { id: 2, score: 20 }, { id: 1, score: 10 } ], [ { id: 2, score: 20 }, { id: 3, score: 15 }, { id: 1, score: 10 } ], [ { id: 3, score: 15 }, { id: 1, score: 10 } ], [ { id: 3, score: 15 } ], [], ], }) )) it('properly handles documents coming in and out of range', assertCompletes(() => observableInterleave({ query: data.order('score').limit(2).watch(), operations: [ data.store({ id: 1, score: 100 }), // after 1, results in 2 data.store({ id: 2, score: 200 }), // after 2, results in 3 data.store({ id: 3, score: 300 }) .concat(data.store({ id: 3, score: 50 })), // after 3, results in 4 data.remove(1), // after 4, results in 5 data.store({ id: 2, score: 20 }), // after 5, results in 6 data.remove(2), // after 6, results in 7 data.remove(3), // after 7, results in 8 ], expected: [ [], // 1 [ { id: 1, score: 100 } ], // 2 [ { id: 1, score: 100 }, { id: 2, score: 200 } ], // 3 [ { id: 3, score: 50 }, { id: 1, score: 100 } ], // 4 [ { id: 3, score: 50 }, { id: 2, score: 200 } ], // 5 [ { id: 2, score: 20 }, { id: 3, score: 50 } ], // 6 [ { id: 3, score: 50 } ], // 7 [], // 8 ], }) )) }} ================================================ FILE: client/test/remove.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMapTo' import 'rxjs/add/operator/toArray' import 'rxjs/add/operator/concat' import 'rxjs/add/operator/map' import 'rxjs/add/operator/ignoreElements' import { assertCompletes, assertThrows, assertErrors, removeAllData, compareWithoutVersion, compareSetsWithoutVersion } from './utils' export default function removeSuite(getData) { return () => { let data const testData = [ { id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 3 }, { id: 'do_not_remove_1' }, { id: 'do_not_remove_2' }, ] before(() => { data = getData() }) // Drop all the existing data before(done => { removeAllData(data, done) }) // Insert the test data and make sure it's in before(assertCompletes(() => data.store(testData).ignoreElements() .concat(data.fetch()) // Make sure it's there .do(res => compareSetsWithoutVersion(res, testData)) )) it('removes a document when passed an id', assertCompletes(() => data.remove(1) .do(res => compareWithoutVersion(res, { id: 1 })) // Let's make sure the removed document isn't there .mergeMapTo(data.find(1).fetch()) // Let's make sure the removed document isn't there .do(res => assert.isNull(res)) )) it('removes a document with an id field', assertCompletes(() => data.remove({ id: 2 }) .do(res => compareWithoutVersion(res, { id: 2 })) // Let's make sure the removed document isn't there .mergeMapTo(data.find(2).fetch()) // Let's make sure the removed document isn't there .do(res => assert.isNull(res)) )) it(`removing a document that doesn't exist doesn't error`, assertCompletes(() => data.remove('abracadabra').do(res => assert.deepEqual(res, { id: 'abracadabra' })) )) it('fails when called with no arguments', assertThrows( 'remove must receive exactly 1 argument', () => data.remove() )) it('fails when called with null', assertThrows( 'The argument to remove must be non-null', () => data.remove(null) )) // Give an error if the user tries to use varargs (to help avoid // confusion) it('fails when called with more than one argument', assertThrows( 'remove must receive exactly 1 argument', () => data.remove(1, 2) )) // Check that the remaining documents are there it(`doesn't remove documents we didn't ask it to`, assertCompletes(() => data.fetch() .map(docs => docs.map(x => x.id)) .do(res => assert.includeMembers( res, [ 'do_not_remove_1', 'do_not_remove_2' ])) )) }} ================================================ FILE: client/test/removeAll.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMapTo' import 'rxjs/add/operator/toArray' import 'rxjs/add/operator/concat' import 'rxjs/add/operator/map' import 'rxjs/add/operator/ignoreElements' import { assertCompletes, assertThrows, assertErrors, removeAllData, compareWithoutVersion, compareSetsWithoutVersion } from './utils' export default function removeAllSuite(getData) { return () => { let data const testData = [ { id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 3 }, { id: 4, a: 4 }, { id: 'do_not_remove_1' }, { id: 'do_not_remove_2' }, ] before(() => { data = getData() }) // Drop all the existing data before(done => { removeAllData(data, done) }) // Insert the test data and make sure it's in before(assertCompletes(() => data.store(testData).ignoreElements() .concat(data.fetch()) // Make sure it's there .do(res => compareSetsWithoutVersion(res, testData)) )) // All right, let's remove a document. The promise resolves with no // arguments. it('removes documents when an array of ids is passed', assertCompletes(() => data.removeAll([ 1 ]) .do(res => compareWithoutVersion(res, { id: 1 })) // Let's make sure the removed document isn't there .mergeMapTo(data.find(1).fetch()) // Let's make sure the removed document isn't there .do(res => assert.isNull(res)) )) // Passing an array of objects to `removeAll` is also ok. it('removes documents when array elements are objects', assertCompletes(() => data.removeAll([ { id: 2 } ]) .do(res => compareWithoutVersion(res, { id: 2 })) // Let's make sure the removed document isn't there .mergeMapTo(data.find(2).fetch()) // Let's make sure the removed document isn't there .do(res => assert.isNull(res)) )) // We can also remove multiple documents it('removes multiple documents by id or as objects', assertCompletes(() => data.removeAll([ 3, 50, { id: 4 } ]).toArray() .do(res => compareWithoutVersion(res, [ { id: 3 }, { id: 50 }, { id: 4 } ])) // Let's make sure the removed document isn't there .mergeMapTo(data.findAll(3, 50, 4).fetch()) // Let's make sure the removed document isn't there .do(res => assert.deepEqual(res, [])) )) // Removing a missing document shouldn't generate an error. it('removes a non-existent document without error', assertCompletes(() => data.removeAll([ 'abracadabra' ]) .do(res => assert.deepEqual(res, { id: 'abracadabra' })), /document was missing/ )) // Calling `removeAll` with an empty array is also ok. it(`doesn't error when an empty array is passed`, assertCompletes(() => data.removeAll([]) .do(res => assert.fail()) )) // But an array with a `null` is an error. it('errors when a null in an array is passed', assertErrors(() => data.removeAll([ null ]), /must be an object/ )) // Calling `removeAll` with anything but a single array is an error. it('throws when no arguments are passed', assertThrows( 'removeAll takes an array as an argument', () => data.removeAll() )) it('throws when more than one argument is passed', assertThrows( 'removeAll must receive exactly 1 argument', () => data.removeAll([ 1 ], 2) )) it('throws when null is passed', assertThrows( 'removeAll takes an array as an argument', () => data.removeAll(null) )) it('throws when passed a number', assertThrows( 'removeAll takes an array as an argument', () => data.removeAll(1) )) it('throws when passed a string', assertThrows( 'removeAll takes an array as an argument', () => data.removeAll('1') )) it('throws when passed an object', assertThrows( 'removeAll takes an array as an argument', () => data.removeAll({ id: 1 }) )) // Check that the remaining documents are there it(`doesn't remove documents not specified`, assertCompletes(() => data.fetch() .map(docs => docs.map(x => x.id)) .do(res => assert.includeMembers( res, [ 'do_not_remove_1', 'do_not_remove_2' ])) )) }} ================================================ FILE: client/test/replace.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMapTo' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion, compareSetsWithoutVersion } from './utils' export default function replaceSuite(getData) { return () => { let data before(() => { data = getData() }) // Let's store a document first, then replace it. it('replaces an existing document completely', assertCompletes(() => data.store({ id: 1, a: { b: 1, c: 1 }, d: 1 }).toArray() // should return an array with an ID of the inserted document. .do(res => compareWithoutVersion(res, [ { id: 1 } ])) // Let's make sure we get back the document that we put in. .mergeMapTo(data.find(1).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion(res, { id: 1, a: { b: 1, c: 1 }, d: 1 })) // Let's replace the document now .mergeMapTo(data.replace({ id: 1, a: { c: 2 } })).toArray() // We should have gotten the ID back again .do(res => compareWithoutVersion(res, [ { id: 1 } ])) // Make sure `replace` replaced the original document .mergeMapTo(data.find(1).fetch()) // Check that the document was updated correctly .do(res => compareWithoutVersion(res, { id: 1, a: { c: 2 } })) )) // The `replace` command replaces documents already in the database. It // errors if the document doesn't exist. it('fails if the document does not already exist', assertErrors(() => data.replace({ id: 1, a: 1, b: 1 }), /document was missing/ )) // It means you can't replace a document without providing an id. it('fails if document does not have an id', assertErrors(() => data.replace({ a: 1, b: 1 }), /"id" is required/ )) // Calling `replace` with `null` is an error. it('fails if null is passed', assertThrows( 'The argument to replace must be non-null', () => data.replace(null) )) // Calling `replace` with `undefined` is also an error. it('fails if undefined is passed', assertThrows( 'The 1st argument to replace must be defined', () => data.replace(undefined) )) it('fails if passed no arguments', assertThrows( 'replace must receive exactly 1 argument', () => data.replace() )) // The `replace` command allows storing multiple documents in one call. // Let's replace a few documents and make sure we get them back. it('allows replacing multiple documents with one call', assertCompletes(() => data.store([ { id: 1, a: { b: 1, c: 1 }, d: 1 }, { id: 2, a: { b: 2, c: 2 }, d: 2 }, ]).toArray() // should return an array with an ID of the inserted document. .do(res => compareWithoutVersion(res, [ { id: 1 }, { id: 2 } ])) // Let's make sure we get back the documents that we put in. .mergeMapTo(data.findAll(1, 2).fetch()) // Check that we get back what we put in. .do(res => compareSetsWithoutVersion(res, [ { id: 1, a: { b: 1, c: 1 }, d: 1 }, { id: 2, a: { b: 2, c: 2 }, d: 2 }, ])) // All right. Let's update the documents now .mergeMapTo(data.replace([ { id: 1, a: { c: 2 } }, { id: 2, d: 3 }, ])) .toArray() // We should have gotten the ID back again .do(res => compareWithoutVersion(res, [ { id: 1 }, { id: 2 } ])) // Make sure `update` updated the documents properly .mergeMapTo(data.findAll(1, 2).fetch()) // Check that we get back what we put in. .do(res => compareSetsWithoutVersion(res, [ { id: 1, a: { c: 2 } }, { id: 2, d: 3 }, ])) )) it('fails if any document in a batch is null', assertErrors(() => data.replace([ { id: 1, a: 1 }, null ]), /must be an object/ )) // Replacing an empty batch of documents is ok, and returns an empty // array. it('allows an empty batch of documents', assertCompletes(() => data.replace([]) .do(res => { // should return an array with the IDs of the documents in // order, including the generated IDS. assert.isArray(res) assert.lengthOf(res, 0) }) )) }} ================================================ FILE: client/test/store.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMapTo' import 'rxjs/add/operator/mergeMap' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion, compareSetsWithoutVersion } from './utils' export default function storeSuite(getData) { return () => { let data before(() => { data = getData() }) // The `store` command stores documents in the database, and overwrites // them if they already exist. it('creates or replaces a document', assertCompletes(() => data.store({ id: 1, a: 1, b: 1 }) // The promise should return an array with an ID of the inserted // document. .do(res => compareWithoutVersion(res, { id: 1 })) // Let's make sure we get back the document that we put in. .mergeMapTo(data.find(1).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion(res, { id: 1, a: 1, b: 1 })) // Let's overwrite the document now .mergeMapTo(data.store({ id: 1, c: 1 })) // We should have gotten the ID back again .do(res => compareWithoutVersion(res, { id: 1 })) // Make sure `store` overwrote the original document .mergeMapTo(data.find(1).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion(res, { id: 1, c: 1 })) )) // If we store a document without an ID, the ID is generated for us. // Let's run the same test as above (store the document and then // overwrite it), but have the ID be generated for us. it('generates ids for documents without them', assertCompletes(() => { let new_id return data.store({ a: 1, b: 1 }).toArray() .do(res => { // The promise should return an array with an ID of the // inserted document. assert.lengthOf(res, 1) assert.isObject(res[0]) assert.isString(res[0].id) new_id = res[0].id }) // Let's make sure we get back the document that we put in. .mergeMap(() => data.find(new_id).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion({ id: new_id, a: 1, b: 1 }, res)) // Let's overwrite the document now .mergeMap(() => data.store({ id: new_id, c: 1 })) // We should have gotten the ID back again .do(res => assert.deepEqual(new_id, res.id)) // Make sure `store` overwrote the original document .mergeMap(() => data.find(new_id).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion({ id: new_id, c: 1 }, res)) })) // Storing `null` is an error. it('fails if null is passed', assertThrows( 'The argument to store must be non-null', () => data.store(null)) ) // Storing `undefined` is also an error. it('fails if undefined is passed', assertThrows( 'The 1st argument to store must be defined', () => data.store(undefined) )) // Storing nothing is an error it('fails if no arguments are passed', assertThrows( 'store must receive exactly 1 argument', () => data.store() )) // The `store` command allows storing multiple documents in one call. // Let's store a few kinds of documents and make sure we get them back. it('allows storing multiple documents in one call', assertCompletes(() => { let new_id_0, new_id_1 return data.store([ {}, { a: 1 }, { id: 1, a: 1 } ]) .toArray() .do(res => { // The promise should return an array with the IDs of the documents // in order, including the generated IDS. assert.isArray(res) assert.lengthOf(res, 3) assert.isString(res[0].id) assert.isString(res[1].id) assert.equal(1, res[2].id) new_id_0 = res[0].id new_id_1 = res[1].id }) // Make sure we get what we put in. .mergeMap(() => data.findAll(new_id_0, new_id_1, 1) .fetch()) // We're supposed to get an array of documents we put in .do(res => compareSetsWithoutVersion(res, [ { id: new_id_0 }, { id: new_id_1, a: 1 }, { id: 1, a: 1 }, ])) })) // If any operation in a batch store fails, everything is reported as a // failure. Note that we're storing `null` below, which is a failure. it('fails if any operation in a batch fails', assertErrors(() => data.store([ { a: 1 }, null, { id: 1, a: 1 } ]), /must be an object/ )) // Storing an empty batch of documents is ok, and returns an empty // array. it('allows storing empty batches', assertCompletes(() => data.store([]).toArray() .do(res => { // The promise should return an array with the IDs of the documents // in order, including the generated IDS. assert.isArray(res) assert.lengthOf(res, 0) }) )) it('stores date objects and retrieves them again', assertCompletes(() => { const originalDate = new Date() return data.store({ date: originalDate }).toArray() .mergeMap(id => data.find(id[0]).fetch()) .do(result => assert.deepEqual(originalDate, result.date)) })) }} ================================================ FILE: client/test/test.html ================================================
================================================ FILE: client/test/test.js ================================================ const BROWSER = (typeof window !== 'undefined') const path = require('path') const glob = require('../src/util/glob') const global = glob() if (BROWSER) { // Use source maps in mocha errors (ordinarily source maps // only work inside Developer Tools) require('source-map-support/browser-source-map-support.js') global.sourceMapSupport.install() } else { // In node, require source-map-support directly. It is listed // as an external dependency in webpack config, so that it is // not bundled here. require('source-map-support').install() } if (BROWSER) { // Expose global.mocha and global.Mocha require('mocha/mocha.js') // Expose globals such as describe() global.mocha.setup('bdd') global.mocha.timeout(10000) } else { global.WebSocket = require('ws') if (__dirname.split(path.sep).pop(-1) === 'test') { if (process.env.NODE_ENV === 'test') { global.Horizon = require('../src/index.js') } else { global.Horizon = require('../lib/index.js') } } else { global.Horizon = require('./horizon.js') } } global.chai = require('chai/chai.js') global.chai.config.showDiff = true global.chai.config.truncateThreshold = 0 global.expect = global.chai.expect global.assert = global.chai.assert // Wait until server is ready before proceeding to tests describe('Waiting until server ready...', function() { this.timeout(60000) it('connected', done => { const tryConnecting = () => { const horizon = Horizon() horizon.onReady(() => { clearInterval(connectInterval) horizon.disconnect() done() }) horizon.connect(() => { // Clients disconnect by themselves on failure }) } const connectInterval = setInterval(tryConnecting, 5000) tryConnecting() }) }) // Load the suite runner require('./api.js') if (BROWSER) { mocha.run() } else { // Run by mocha command } ================================================ FILE: client/test/times.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/toArray' import { assertCompletes, compareWithoutVersion, removeAllData } from './utils' export default function timesSuite(getData) { return () => { let data before(() => { data = getData() }) after(done => { removeAllData(data, done) }) let range = count => Array.from(Array(count).keys()) beforeEach(assertCompletes(() => { const rows = range(16).map(i => ( { id: i, value: i % 4, time: new Date(Math.floor(i / 4)), } )) return data.store(rows).toArray() .do(res => { assert.isArray(res) assert.lengthOf(res, 16) }) })) it('finds a document by a field with a time value', assertCompletes(() => data.find({ time: new Date(0) }).fetch() .do(res => compareWithoutVersion(res, { id: 0, time: new Date(0), value: 0, })) )) it('finds a document by a time field and another field', assertCompletes(() => data.find({ value: 1, time: new Date(3) }).fetch() .do(res => compareWithoutVersion(res, { id: 13, value: 1, time: new Date(3), })) )) it('finds all documents by a field with a time value', assertCompletes(() => data.findAll({ time: new Date(2) }).fetch() .do(res => compareWithoutVersion(res, range(4).map(i => ({ id: i + 8, value: i, time: new Date(2), })))) )) it('finds all documents by a time field and another field', assertCompletes(() => data.findAll({ value: 2, time: new Date(3) }).fetch() .do(res => compareWithoutVersion(res, [ { id: 14, value: 2, time: new Date(3), } ])) )) it('finds all documents bounded above by a time', assertCompletes(() => data.findAll({ value: 3 }) .above({ time: new Date(1) }) .fetch() .do(res => compareWithoutVersion(res, range(3).map(i => ({ id: 3 + (i + 1) * 4, value: 3, time: new Date(i + 1), })))) )) it('finds all documents between two times', assertCompletes(() => data.findAll({ value: 2 }) .above({ time: new Date(1) }) .below({ time: new Date(3) }) .fetch() .do(res => compareWithoutVersion(res, [ { id: 6, value: 2, time: new Date(1) }, { id: 10, value: 2, time: new Date(2) }, ])) )) }} ================================================ FILE: client/test/unit/ast.js ================================================ import { applyChange } from '../../src/ast' export default function unitAstSuite() { describe('applyChanges', () => { it('correctly changes an item with an array id in place', done => { const existingArray = [ { id: [ 'A', 'B' ], val: 3 }, { id: [ 'B', 'C' ], val: 4 }, ] const change = { type: 'change', new_offset: null, old_offset: null, old_val: { id: [ 'B', 'C' ], val: 4, }, new_val: { id: [ 'B', 'C' ], val: 5, }, } const expected = [ { id: [ 'A', 'B' ], val: 3 }, { id: [ 'B', 'C' ], val: 5 }, ] const obtained = applyChange(existingArray, change) assert.deepEqual(obtained, expected) done() }) it('correctly deletes an uninitial item with an array id', done => { const existingArray = [ { id: [ 'A', 'B' ], val: 3 }, { id: [ 'B', 'C' ], val: 4 }, ] const change = { type: 'uninitial', old_val: { id: [ 'B', 'C' ], val: 4, }, } const expected = [ { id: [ 'A', 'B' ], val: 3 }, ] const obtained = applyChange(existingArray, change) assert.deepEqual(obtained, expected) done() }) }) } ================================================ FILE: client/test/unit/auth.js ================================================ import { TokenStorage, FakeStorage } from '../../src/auth' export default function unitAuthSuite() { describe('TokenStorage', () => { let fakeStorage let tokenStore beforeEach(() => { fakeStorage = new FakeStorage() tokenStore = new TokenStorage({ authType: 'token', storage: fakeStorage, path: 'testHorizon', }) }) it('sets a token and retrieves it back', done => { const fakeData = 'some kinda long テスト string' tokenStore.set(fakeData) const obtained = tokenStore.get() assert.equal(obtained, fakeData) done() }) it('overwrites a token for the same path', done => { const string1 = 'Test string 1' const string2 = 'Test string 2' tokenStore.set(string1) tokenStore.set(string2) const obtained = tokenStore.get() assert.equal(obtained, string2) done() }) it('keeps storage from different paths separate', done => { const otherTokens = new TokenStorage({ authType: 'token', path: 'secondHorizon', storage: fakeStorage, }) tokenStore.set('A') otherTokens.set('B') const obtainedA = tokenStore.get() assert.equal(obtainedA, 'A') const obtainedB = otherTokens.get() assert.equal(obtainedB, 'B') done() }) it('removes tokens', done => { tokenStore.set('A') tokenStore.remove() assert.isUndefined(tokenStore.get()) done() }) it('removes tokens independently by path', done => { const otherToken = new TokenStorage({ authType: 'token', path: 'anotherPath', storage: fakeStorage, }) tokenStore.set('A') otherToken.set('B') tokenStore.remove() assert.equal(otherToken.get(), 'B') assert.isUndefined(tokenStore.get()) done() }) }) } ================================================ FILE: client/test/unit/utilsTest.js ================================================ import validIndexValue from '../../src/util/valid-index-value' export default function unitUtilsSuite() { describe('validIndexValue', () => { function assertValid(value) { assert.isTrue(validIndexValue(value), `${JSON.stringify(value)} should be valid`) } function assertInvalid(value) { assert.isFalse(validIndexValue(value), `${JSON.stringify(value)} should be invalid`) } it('disallows nulls', done => { const value = null assertInvalid(value) done() }) it('disallows undefined', done => { const value = undefined assertInvalid(value) done() }) it('allows booleans', done => { const value = true assertValid(value) done() }) it('allows strings', done => { const value = 'some kinda string test' assertValid(value) done() }) it('allows numbers', done => { const value = 12.33 assertValid(value) done() }) it('allows Dates', done => { const value = new Date() assertValid(value) done() }) it('allows ArrayBuffers', done => { const value = new ArrayBuffer(1) assertValid(value) done() }) it('disallows bare objects', done => { const value = { a: 1 } assertInvalid(value) done() }) it('allows arrays of primitives', done => { const value = [ true, false, 1, "foo", new Date(), new ArrayBuffer(1) ] assertValid(value) done() }) it('allows empty arrays', done => { const value = [ ] assertValid(value) done() }) it('allows deeply nested arrays', done => { const value = [ [ ], [ [ [ [ 0, 1, [ ] ], [ 1 ] ] ] ] ] assertValid(value) done() }) it('disallows arrays containing objects', done => { const value = [ 1, { a: 1 } ] assertInvalid(value) done() }) it('disallows arrays with deeply nested objects', done => { const value = [ [ ], [ [ [ [ ], [ {} ] ] ] ] ] assertInvalid(value) done() }) }) } ================================================ FILE: client/test/update.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMapTo' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion, compareSetsWithoutVersion } from './utils' export default function updateSuite(getData) { return () => { let data before(() => { data = getData() }) // Let's store a document first, then update it. it('allows updating an existing document', assertCompletes(() => data.store({ id: 1, a: { b: 1, c: 1 }, d: 1 }).toArray() // should return an array with an ID of the inserted document. .do(res => compareWithoutVersion([ { id: 1 } ], res)) // Let's make sure we get back the document that we put in. .mergeMapTo(data.find(1).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion(res, { id: 1, a: { b: 1, c: 1 }, d: 1 })) // Let's update the document now .mergeMapTo(data.update({ id: 1, a: { c: 2 } })).toArray() // We should have gotten the ID back again .do((res) => compareWithoutVersion([ { id: 1 } ], res)) // Make sure `upsert` updated the original document .mergeMapTo(data.find(1).fetch()) // Check that the document was updated correctly .do(res => compareWithoutVersion(res, { id: 1, a: { b: 1, c: 2 }, d: 1 })) )) // The `update` command updates documents already in the database. It // errors if the document doesn't exist. it(`fails if document doesn't exist`, assertErrors(() => data.update({ id: 1, a: 1, b: 1 }), /The document was missing/ )) // It means you can't update a document without providing an id. it('fails if document has no id provided', assertErrors(() => data.update({ a: 1, b: 1 }), /"id" is required/ )) // Calling `update` with `null` is an error. it('fails if null is passed', assertThrows( 'The argument to update must be non-null', () => data.update(null) )) // Calling `update` with `undefined` is also an error. it('fails if undefined is passed', assertThrows( 'The 1st argument to update must be defined', () => data.update(undefined) )) it('fails if no arguments are passed', assertThrows( 'update must receive exactly 1 argument', () => data.update() )) // The `update` command allows storing multiple documents in one call. // Let's update a few documents and make sure we get them back. it('allows updating multiple documents in one call', assertCompletes(() => data.store([ { id: 1, a: { b: 1, c: 1 }, d: 1 }, { id: 2, a: { b: 2, c: 2 }, d: 2 }, ]).toArray() // should return an array with an ID of the inserted document. .do(res => compareWithoutVersion([ { id: 1 }, { id: 2 } ], res)) // Let's make sure we get back the documents that we put in. .mergeMapTo(data.findAll(1, 2).fetch()) // Check that we get back what we put in. .do(res => compareSetsWithoutVersion(res, [ { id: 1, a: { b: 1, c: 1 }, d: 1 }, { id: 2, a: { b: 2, c: 2 }, d: 2 }, ])) // All right. Let's update the documents now .mergeMapTo(data.update([ { id: 1, a: { c: 2 } }, { id: 2, d: 3 } ])) .toArray() // We should have gotten the ID back again .do(res => compareWithoutVersion(res, [ { id: 1 }, { id: 2 } ])) // Make sure `update` updated the documents properly .mergeMapTo(data.findAll(1, 2).fetch()) // Check that we get back what we put in. .do(res => compareSetsWithoutVersion(res, [ { id: 1, a: { b: 1, c: 2 }, d: 1 }, { id: 2, a: { b: 2, c: 2 }, d: 3 }, ])) )) it('fails if any document is null', assertErrors(() => data.update([ { id: 1, a: 1 }, null ]), /must be an object/ )) // Updating an empty batch of documents is ok, and returns an empty // array. it('allows updating an empty batch', assertCompletes(() => data.update([]) .do(res => { // should return an array with the IDs of the documents in // order, including the generated IDS. assert.isArray(res) assert.lengthOf(res, 0) }) )) }} ================================================ FILE: client/test/upsert.js ================================================ import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMapTo' import 'rxjs/add/operator/mergeMap' import 'rxjs/add/operator/toArray' import { assertCompletes, assertThrows, assertErrors, compareWithoutVersion, compareSetsWithoutVersion } from './utils' export default function upsertSuite(getData) { return () => { let data before(() => { data = getData() }) // The `upsert` command stores documents in the database, and updates // them if they already exist. it(`updates existing documents or creates them if they don't exist`, assertCompletes(() => data.upsert({ id: 1, a: { b: 1, c: 1 }, d: 1 }).toArray() // should return an array with an ID of the inserted document. .do(res => compareWithoutVersion(res, [ { id: 1 } ])) // Let's make sure we get back the document that we put in. .mergeMapTo(data.find(1).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion(res, { id: 1, a: { b: 1, c: 1 }, d: 1 })) // Let's update the document now .mergeMapTo(data.upsert({ id: 1, a: { c: 2 } })).toArray() // We should have gotten the ID back again .do(res => compareWithoutVersion([ { id: 1 } ], res)) // Make sure `upsert` updated the original document .mergeMapTo(data.find(1).fetch()) // Check that the document was updated correctly .do(res => compareWithoutVersion(res, { id: 1, a: { b: 1, c: 2 }, d: 1 })) )) // If we upsert a document without an ID, the ID is generated for us. // Let's run the same test as above (store the document and then update // it), but have the ID be generated for us. it('generates ids for documents without them', assertCompletes(() => { let new_id return data.upsert({ a: { b: 1, c: 1 }, d: 1 }).toArray() .do(res => { // should return an array with an ID of the inserted document. assert.isArray(res) assert.lengthOf(res, 1) assert.isString(res[0].id) new_id = res[0].id }) // Let's make sure we get back the document that we put in. .mergeMap(() => data.find(new_id).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion(res, { id: new_id, a: { b: 1, c: 1 }, d: 1 })) // Let's update the document now .mergeMap(() => data.upsert({ id: new_id, a: { c: 2 } })).toArray() // We should have gotten the ID back again .do(res => compareWithoutVersion(res, [ { id: new_id } ])) // Make sure `upsert` updated the original document .mergeMap(() => data.find(new_id).fetch()) // Check that we get back what we put in. .do(res => compareWithoutVersion(res, { id: new_id, a: { b: 1, c: 2 }, d: 1 })) })) // Upserting `null` is an error. it('fails if null is passed', assertThrows( 'The argument to upsert must be non-null', () => data.upsert(null) )) // Upserting `undefined` is also an error. it('fails if undefined is passed', assertThrows( 'The 1st argument to upsert must be defined', () => data.upsert(undefined) )) it('fails if no arguments are passed', assertThrows( 'upsert must receive exactly 1 argument', () => data.upsert() )) // The `upsert` command allows storing multiple documents in one call. // Let's upsert a few kinds of documents and make sure we get them back. it('allows upserting multiple documents in one call', assertCompletes(() => { let new_id_0, new_id_1 return data.upsert([ {}, { a: 1 }, { id: 1, a: 1 } ]).toArray() .do(res => { // should return an array with the IDs of the documents in // order, including the generated IDS. assert.isArray(res) assert.lengthOf(res, 3) assert.isString(res[0].id) assert.isString(res[1].id) assert.equal(1, res[2].id) new_id_0 = res[0].id new_id_1 = res[1].id }) // Make sure we get what we put in. .mergeMap(() => data.findAll(new_id_0, new_id_1, 1).fetch()) // We're supposed to get an array of documents we put in .do(res => compareSetsWithoutVersion(res, [ { id: new_id_0 }, { id: new_id_1, a: 1 }, { id: 1, a: 1 }, ])) })) // If any operation in a batch upsert fails, everything is reported // as a failure. it('errors if given a null document', assertErrors(() => data.upsert([ { a: 1 }, null ]), /must be an object/ )) // Upserting an empty batch of documents is ok, and returns an empty // array. it('allows upserting an empty batch', assertCompletes(() => data.upsert([]).toArray() .do(res => { // should return an array with the IDs of the documents in // order, including the generated IDS. assert.lengthOf(res, 0) }) )) }} ================================================ FILE: client/test/utils.js ================================================ import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/empty' import 'rxjs/add/operator/toArray' import 'rxjs/add/operator/do' import 'rxjs/add/operator/mergeMap' import 'rxjs/add/operator/mergeMapTo' import 'rxjs/add/operator/take' import 'rxjs/add/operator/ignoreElements' export function removeAllDataObs(collection) { // Read all elements from the collection return collection.fetch() // all documents in the collection .do() .mergeMap(docs => collection.removeAll(docs)) .mergeMapTo(collection.fetch()) .do(remaining => assert.deepEqual([], remaining)) } export function removeAllData(collection, done) { removeAllDataObs(collection).subscribe(doneObserver(done)) } // Used to subscribe to observables and call done appropriately function doneObserver(done) { return { next() {}, error(err = new Error()) { done(err) }, complete() { done() }, } } // Used to subscribe to observables when an error is expected function doneErrorObserver(done, regex) { return { next() {}, error(err) { this.finished = true if (regex && regex.test(err.message)) { done() } else { done(err) } }, complete() { if (!this.finished) { done(new Error('Unexpectedly completed')) } }, } } // Used to check for stuff that should throw an exception, rather than // erroring the observable stream export function assertThrows(message, callback) { const f = done => { try { callback() done(new Error("Didn't throw an exception")) } catch (err) { if (err.message === message) { done() } else { done(new Error('Threw the wrong exception. ' + `Expected "${message}", got "${err.message}"`)) } } } f.toString = () => `assertThrows(\n'${message}',\n ${callback}\n)` return f } export function assertCompletes(observable) { const f = done => observable().subscribe(doneObserver(done)) f.toString = () => `assertCompletes(\n(${observable}\n)` return f } export function assertErrors(observable, regex) { const f = done => observable().subscribe(doneErrorObserver(done, regex)) f.toString = () => observable.toString() return f } // Useful for asynchronously interleaving server actions with a // changefeed and testing the changes are as expected. // // Takes a sequence of actions and a changefeed query. Executes the // next action every time a value comes in over the feed. Asserts that // the expected values are returned from the final observable. The // changefeed is automatically limited to the length of the expected // array. Accepts a `debug` argument that receives every element in // the changefeed export function observableInterleave(options) { const query = options.query const operations = options.operations const expected = options.expected const equality = options.equality || compareWithoutVersion const debug = options.debug || (() => {}) const values = [] return query .take(expected.length) .do(debug) .mergeMap((val, i) => { values.push(val) if (i < operations.length) { return operations[i].ignoreElements() } else { return Observable.empty() } }) .do({ complete() { equality(expected, values) } }) } const withoutVersion = function withoutVersion(value) { if (Array.isArray(value)) { const modified = [ ] for (const item of value) { modified.push(withoutVersion(item)) } return modified } else if (typeof value === 'object') { const modified = Object.assign({ }, value) delete modified['$hz_v$'] return modified } else { return value } } // Compare write results - ignoring the new version field ($hz_v$) export function compareWithoutVersion(actual, expected, message) { return assert.deepEqual(withoutVersion(actual), withoutVersion(expected), message) } export function compareSetsWithoutVersion(actual, expected, message) { return assert.sameDeepMembers(withoutVersion(actual), withoutVersion(expected), message) } ================================================ FILE: client/webpack.config.js ================================================ const BUILD_ALL = (process.env.NODE_ENV === 'production') const build = require('./webpack.horizon.config.js') const test = require('./webpack.test.config.js') if (BUILD_ALL) { module.exports = [ build({ FILENAME: 'horizon-dev.js', DEV_BUILD: true, POLYFILL: true, }), build({ FILENAME: 'horizon.js', DEV_BUILD: false, POLYFILL: true, }), build({ FILENAME: 'horizon-core-dev.js', DEV_BUILD: true, POLYFILL: false, }), build({ FILENAME: 'horizon-core.js', DEV_BUILD: false, POLYFILL: false, }), test, ] } else { module.exports = [ build({ // same filename as prod build to simplify switching FILENAME: 'horizon.js', DEV_BUILD: true, POLYFILL: true, }), test, ] } ================================================ FILE: client/webpack.horizon.config.js ================================================ const path = require('path') const BannerPlugin = require('webpack/lib/BannerPlugin') const DedupePlugin = require('webpack/lib/optimize/DedupePlugin') const DefinePlugin = require('webpack/lib/DefinePlugin') const OccurrenceOrderPlugin = require( 'webpack/lib/optimize/OccurrenceOrderPlugin') const UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin') const ProvidePlugin = require('webpack/lib/ProvidePlugin') module.exports = function(buildTarget) { const FILENAME = buildTarget.FILENAME const DEV_BUILD = buildTarget.DEV_BUILD const POLYFILL = buildTarget.POLYFILL const SOURCEMAPS = !process.env.NO_SOURCEMAPS return { entry: { horizon: POLYFILL ? './src/index-polyfill.js' : './src/index.js', }, target: 'web', output: { path: path.resolve(__dirname, 'dist'), filename: FILENAME, library: 'Horizon', // window.Horizon if loaded by a script tag libraryTarget: 'umd', pathinfo: DEV_BUILD, // Add module filenames as comments in the bundle devtoolModuleFilenameTemplate: DEV_BUILD ? function(file) { if (file.resourcePath.indexOf('webpack') >= 0) { return `webpack:///${file.resourcePath}` } else { // Show correct paths in stack traces return path.join('..', file.resourcePath) .replace(/~/g, 'node_modules') } } : null, }, externals: [ function(context, request, callback) { // Selected modules are not packaged into horizon.js. Webpack // allows them to be required natively at runtime, either from // filesystem (node) or window global. if (!POLYFILL && /^rxjs\/?/.test(request)) { callback(null, { // If loaded via script tag, has to be at window.Rx when // library loads root: 'Rx', // Otherwise imported via `require('rx')` commonjs: 'rxjs', commonjs2: 'rxjs', amd: 'rxjs' }) } else { callback() } }, { ws: 'commonjs ws' } ], debug: DEV_BUILD, devtool: SOURCEMAPS ? (DEV_BUILD ? 'source-map' : 'source-map') : false, module: { noParse: [ ], preLoaders: [], loaders: [ { test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader', query: { cacheDirectory: true, extends: path.resolve(__dirname, 'package.json'), }, }, ], }, plugins: [ new BannerPlugin('__LICENSE__'), // Possibility to replace constants such as `if (__DEV__)` // and thus strip helpful warnings from production build: new DefinePlugin({ 'process.env.NODE_ENV': (DEV_BUILD ? 'development' : 'production'), }), new ProvidePlugin({ Promise: 'es6-promise', }), ].concat(DEV_BUILD ? [] : [ new DedupePlugin(), new OccurrenceOrderPlugin(), new UglifyJsPlugin({ compress: { screw_ie8: false, warnings: false, }, mangle: { except: [], }, }), ]), node: { // Don't include unneeded node libs in package process: false, fs: false, __dirname: false, __filename: false, }, } } ================================================ FILE: client/webpack.test.config.js ================================================ const path = require('path') const CopyWebpackPlugin = require('copy-webpack-plugin') const DEV_BUILD = (process.env.NODE_ENV !== 'production') const SOURCEMAPS = !process.env.NO_SOURCEMAPS module.exports = { entry: { test: './test/test.js', }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js', pathinfo: DEV_BUILD, // Add module filenames as comments in the bundle devtoolModuleFilenameTemplate: function(file) { if (file.resourcePath.indexOf('webpack') >= 0) { return `webpack:///${file.resourcePath}` } else { // Show correct paths in stack traces return path.join('..', file.resourcePath).replace(/~/g, 'node_modules') } }, }, target: 'web', debug: DEV_BUILD, devtool: SOURCEMAPS ? 'source-map' : false, externals: { // These modules are not packaged into test.js. Webpack allows // them to be required natively at runtime when the tests are run // in node './horizon.js': 'commonjs ./horizon.js', ws: 'commonjs ws', 'source-map-support': 'commonjs source-map-support', }, module: { noParse: [ // Pre-built files don't need parsing /mocha\/mocha\.js/, /chai\/chai\.js/, /lodash\/lodash\.js/, /source-map-support/, /rxjs\/bundles\/Rx\.umd\.js/, ], loaders: [ { test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader', query: { cacheDirectory: true, presets: [ 'babel-preset-es2015-loose', { plugins: [ [ 'babel-plugin-transform-runtime', { polyfill: false } ], ] }, ], }, }, ], }, node: { // Don't include unneeded node libs in package process: false, fs: false, __dirname: false, __filename: false, }, plugins: [ new CopyWebpackPlugin([ { from: './test/test.html' }, { from: './node_modules/mocha/mocha.css' }, ]), ], } ================================================ FILE: docker-compose.dev.yml ================================================ rethinkdb: image: rethinkdb ports: - "28015:28015" - "8080:8080" horizon: image: rethinkdb/horizon command: su -s /bin/sh horizon -c "hz serve --dev --connect rethinkdb://rethinkdb:28015 --bind all /usr/app" volumes: - ./:/usr/app links: - rethinkdb ports: - "8181:8181" ================================================ FILE: docker-compose.prod.yml ================================================ rethinkdb: image: rethinkdb ports: - "28015:28015" - "8080:8080" horizon: image: rethinkdb/horizon command: su -s /bin/sh horizon -c "hz serve --connect rethinkdb://rethinkdb:28015 --bind all /usr/app" volumes: - ./:/usr/app links: - rethinkdb ports: - "8181:8181" ================================================ FILE: examples/.eslintrc ================================================ { "rules": { "array-bracket-spacing": [ 2, "always" ], "arrow-parens": [ 2, "as-needed" ], "arrow-spacing": [ 2 ], "block-spacing": [ 2, "always" ], "brace-style": [ 2, "1tbs", { "allowSingleLine": true } ], "comma-dangle": [ 2, "always-multiline" ], "comma-spacing": [ 2 ], "comma-style": [ 2, "last" ], "constructor-super": [ 2 ], "curly": [ 2, "all" ], "dot-notation": [ 2 ], "eqeqeq": [ 2, "allow-null" ], "func-style": [ 2, declaration, { "allowArrowFunctions": true} ], "indent": [ 2, 2 ], "key-spacing": [ 2 ], "linebreak-style": [ 2, "unix" ], "new-parens": [ 2 ], "no-array-constructor": [ 2 ], "no-case-declarations": [ 2 ], "no-class-assign": [ 2 ], "no-console": [ 0 ], "no-const-assign": [ 2 ], "no-dupe-class-members": [ 2 ], "no-eval": [ 2 ], "no-extend-native": [ 2 ], "no-extra-semi": [ 2 ], "no-floating-decimal": [ 2 ], "no-implicit-coercion": [ 2 ], "no-implied-eval": [ 2 ], "no-invalid-this": [ 2 ], "no-labels": [ 2 ], "no-lonely-if": [ 2 ], "no-mixed-requires": [ 2 ], "no-multi-spaces": [ 2 ], "no-multi-str": [ 2 ], "no-multiple-empty-lines": [ 2, { "max": 2, "maxEOF": 1 } ], "no-native-reassign": [ 2 ], "no-new-func": [ 2 ], "no-new-object": [ 2 ], "no-new-require": [ 2 ], "no-new-wrappers": [ 2 ], "no-param-reassign": [ 2 ], "no-proto": [ 2 ], "no-return-assign": [ 2 ], "no-self-compare": [ 2 ], "no-sequences": [ 2 ], "no-shadow": [ 2 ], "no-shadow-restricted-names": [ 2 ], "no-this-before-super": [ 2 ], "no-throw-literal": [ 2 ], "no-trailing-spaces": [ 2 ], "no-unneeded-ternary": [ 2 ], "no-use-before-define": [ 2, "nofunc" ], "no-var": [ 2 ], "no-void": [ 2 ], "no-with": [ 2 ], "object-curly-spacing": [ 2, "always" ], "one-var": [ 2, { "uninitialized": "always", "initialized": "never" } ], "operator-assignment": [ 2, "always" ], "operator-linebreak": [ 2, "after" ], "padded-blocks": [ 2, "never" ], "quote-props": [ 2, "as-needed" ], "quotes": [ 2, "single" ], "semi-spacing": [ 2 ], "space-after-keywords": [ 2, "always" ], "space-before-blocks": [ 2, "always" ], "space-before-function-paren": [ 2, "never" ], "space-before-keywords": [ 2, "always" ], "space-in-parens": [ 2, "never" ], "space-infix-ops": [ 2 ], "space-return-throw-case": [ 2 ], "space-unary-ops": [ 2 ], "spaced-comment": [ 2, "always" ], "strict": [ 2, "global" ], "wrap-iife": [ 2, "inside" ], "yoda": [ 2, "never" ], }, "env": { "es6": true, "node": true, "mocha": true }, "extends": "eslint:recommended" } ================================================ FILE: examples/README.md ================================================ # Community Examples Coming soon! # Horizon Extension Examples We decided it was important to leverage Horizon alongside all your normal app functions. Here are some basic examples of how one would extend your JS web framework of choice with Horizon: [express](https://github.com/strongloop/express), [koa](https://github.com/koajs/koa), or [hapi](https://github.com/hapijs) frameworks with Horizon. * [Horizon & Express](/examples/express-server) * [Horizon & Koa](/examples/koa-server) * [Horizon & Hapi](/examples/hapi-server) # Horizon App Examples And finally, we've created a few example applications to help you get started with Horizon. In each directory you will find a `dist` folder which will have all the files needed to serve the example application. In some of the TodoMVC examples, you will find a `package.json` inside the `dist` directory, and you will need to run `npm install` for some of their dependencies. If you have any questions just ask on [Slack](http://slack.rethinkdb.com) or [Twitter](https://twitter.com/rethinkdb)! ## AngularJS * [AngularJS Todo App](/examples/angularjs-todo-app) ## CycleJS * [CycleJS Chat App](/examples/cyclejs-chat-app) ## React * [React Chat App](/examples/react-chat-app/) * [React Todo App](/examples/react-todo-app/) ## RiotJS * [RiotJS Chat App](/examples/riotjs-chat-app) ## Vuejs * [Vue.js Chat App](/examples/vue-chat-app/) * [Vue Todo App](/examples/vue-todo-app/) ================================================ FILE: examples/angularjs-todo-app/.gitignore ================================================ node_modules/ rethinkdb_data **/*.log .hz/ ================================================ FILE: examples/angularjs-todo-app/README.md ================================================ #TodoMVC A basic example of using [AngularJS](http://angularjs.org/) and Horizon to create real-time TodoMVC app. ## Prerequisites - [RethinkDB](https://www.rethinkdb.com/docs/install/) (The open-source database for the realtime web) - [Horizon](https://github.com/rethinkdb/horizon/) (A realtime, open-source backend for JavaScript apps) ## Installing ``` $ git clone git@github.com:rethinkdb/horizon.git $ cd horizon/examples/angularjs-todo-app $ hz init $ cd dist && npm install $ cd .. && hz serve --dev ``` ## Credit This TodoMVC application is built based on the [todomvc-angularjs-horizon](https://github.com/endetti/todomvc-angularjs-horizon). ================================================ FILE: examples/angularjs-todo-app/dist/css/style.css ================================================ [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { display: none !important; } ================================================ FILE: examples/angularjs-todo-app/dist/index.html ================================================

todos

{{Todo.remainingCount}}
================================================ FILE: examples/angularjs-todo-app/dist/js/app.js ================================================ angular.module('todomvc', []); ================================================ FILE: examples/angularjs-todo-app/dist/js/controllers/TodoController.js ================================================ (function () { 'use strict'; angular .module('todomvc') .controller('TodoCtrl', TodoCtrl); TodoCtrl.$inject = ['$filter', '$q']; function TodoCtrl($filter, $q) { const hz = new Horizon(); const Tasks = hz("todomvc_tasks"); self = this; self.allCompleted = false; self.addTask = addTask; self.removeTask = removeTask; self.editTask = editTask; self.editTaskSave = editTaskSave; self.editTaskCancel = editTaskCancel; self.toggleCompleted = toggleCompleted; self.toggleAll = toggleAll; self.removeCompletedTasks = removeCompletedTasks; init(); function init() { Tasks.order("date", "ascending").watch().subscribe(function (tasks) { var defer = $q.defer(); defer.resolve(tasks); defer.promise.then(function (tasks) { self.remainingCount = $filter('filter')(tasks, { completed: false }).length; self.completedCount = tasks.length - self.remainingCount; self.allCompleted = !self.remainingCount; self.tasks = tasks; }); }); } function addTask() { if (!self.taskTitle) return; Tasks.store({ title: self.taskTitle.trim(), completed: false, date: new Date() }); self.taskTitle = ''; } function removeTask(task) { Tasks.remove(task); } function editTask(task) { self.editedTask = task; self.taskCopy = angular.copy(task); } function editTaskSave(task) { self.editedTask = null; Tasks.update(task); } function editTaskCancel(task) { task.title = self.taskCopy.title; self.editedTask = null; } function toggleCompleted(task) { Tasks.update(task); } function toggleAll() { self.tasks.forEach(function (task) { task.completed = !self.allCompleted; }); toggleCompleted(self.tasks); } function removeCompletedTasks() { var completed = self.tasks.filter(function (task) { return task.completed === true; }); Tasks.remove(completed); } }; })(); ================================================ FILE: examples/angularjs-todo-app/dist/package.json ================================================ { "private": true, "dependencies": { "angular": "^1.5.8", "todomvc-app-css": "^2.0.0", "todomvc-common": "^1.0.1" } } ================================================ FILE: examples/auth-app/.gitignore ================================================ node_modules dist/node_modules ================================================ FILE: examples/auth-app/README.md ================================================ ================================================ FILE: examples/auth-app/dist/app.css ================================================ #steps { margin-top:25%; } #success > div > div { margin-top:10%; text-align:center; color:green; } #login #login-button { height: 100px; } ================================================ FILE: examples/auth-app/dist/app.js ================================================ 'use strict' var horizon = Horizon({ authType: 'anonymous' }); const crossOut = (element) => { element.setAttribute("style", "text-decoration: line-through; color: grey;"); } // #1 - Connect to Horizon server horizon.onReady(() => { crossOut(document.querySelector('#connection-success')); }) horizon.connect(); // #2 - Ensure Github OAuth client_id & client_secret added fetch("/horizon/auth_methods").then((response) => { return response.text(); }).then((json) => { const strategies = JSON.parse(json); if (strategies.hasOwnProperty('github')) { console.log(strategies) crossOut(document.querySelector('#github-configured')); const login = document.querySelector('#login-button'); login.disabled = false; login.className = login.className.replace('btn-error-outline', 'btn-primary-outline'); } }) // #3 - Test OAuth pathing with Github var horizon = Horizon({ authType: 'token' }); horizon.connect() if (!horizon.hasAuthToken()) { console.log("no auth token") document.querySelector('#login-button').addEventListener('click', () => { console.log('clicked'); horizon.authEndpoint('github').subscribe((endpoint) => { window.location.pathname = endpoint; }); }); } else { const user = horizon.currentUser().fetch().forEach((user) => { document.querySelector('#success').innerHTML = `

Authentication successful
Your user ID is ${user.id}

`; }); } ================================================ FILE: examples/auth-app/dist/index.html ================================================

This small example will help you get started with setting up OAuth on Horizon. Here's what you need to get OAuth going:

  1. Create an application at Github.
  2. Add the client id and client secret to the configuration file
  3. Ensure that you can find Github in the registered providers
  4. Click the authenticate with Github button

  1. Connect to Horizon
  2. Configure Github OAuth
================================================ FILE: examples/cyclejs-chat-app/README.md ================================================ # Horizon Chat example using Cycle.js
![](https://i.imgur.com/XFostB8.gif)
## Ideas for future - [ ] implement `presence` - [ ] implement `geo` to display users differently that are geographically near you - [ ] implement `auth` to allow users to log in via Github, Twitter, etc and have their handle appear. ================================================ FILE: examples/cyclejs-chat-app/dist/app.css ================================================ /* always present */ .expand-transition { transition: all .4s ease; height: 30px; padding: 10px; background-color: #eee; overflow: hidden; } /* .expand-enter defines the starting state for entering */ /* .expand-leave defines the ending state for leaving */ .expand-enter, .expand-leave { height: 0; padding: 0 10px; opacity: 0; } #app ul { list-style-type: none; } .message { height: 50px; } .message img { vertical-align:middle; } .message .text { vertical-align:middle; margin-left:5px; font-family: 'Source Code Pro', "Raleway", "Helvetica Neue"; font-size:20px; } .message .datetime { color:darkgrey; } #input { margin-bottom:10%; } #input input { height:100px; font-size:5rem; padding:10px; font-family: 'Source Code Pro', "Raleway", "Helvetica Neue"; } ================================================ FILE: examples/cyclejs-chat-app/dist/app.js ================================================ 'use strict' ;(function() { const makeDOMDriver = CycleDOM.makeDOMDriver const div = CycleDOM.div const input = CycleDOM.input const ul = CycleDOM.ul const li = CycleDOM.li const img = CycleDOM.img const span = CycleDOM.span // Intent grabs things from our sources and pulls out what events // we're interested in, as well as mapping to useful data (so // streams of strings, instead of observables of text input events) function intent(sources) { const horizonCollection = sources.horizon(sources.config.collectionName) // Every time the enter key is hit in the text box const enterHit$ = sources.DOM .select('#input') .events('keydown') .filter(ev => ev.keyCode === 13) .map(ev => ev.target.value || null) .share() // Every time the text in the input box changes const inputChange$ = sources.DOM .select('#input') .events('input') .map(ev => ev.target.value) // all our chats from the horizon server const messages$ = horizonCollection .order('datetime', 'descending') .limit(sources.config.chatLength) .watch() // Every time the user hits enter, store the message to the server // Note: this is an observable of observables const writeOps$$ = enterHit$.map(text => horizonCollection.store({ authorId: sources.config.authorId, datetime: new Date(), text, }) ) // This merges the stream of the input values with a stream that // returns null whenever enter is hit. This will clear the text // box after submitting. const inputValue$ = inputChange$.merge(enterHit$.map(() => null)) return { inputValue$, writeOps$$, messages$, } } // Model takes our action streams and turns them into the stream of // app states function model(inputValue$, messages$) { return Rx.Observable.combineLatest( inputValue$.startWith(null), messages$.startWith([]), (inputValue, messages) => ({ messages, inputValue }) ) } // View takes the state and create a stream of virtual-dom trees for // the app. function view(state$) { // Displayed for each chat message. function chatMessage(msg) { return li('.message', { key: msg.id }, [ img({ height: '50', width: '50', src: `http://api.adorable.io/avatars/50/${msg.authorId}.png`, }), span('.text', msg.text), ]) } return state$.map( state => div([ div('.row', ul(state.messages.map(chatMessage))), div('#input.row', input('.u-full-width', { value: state.inputValue, autoFocus: true })), ]) ) } // In main we just wire everything together function main(sources) { const intents = intent(sources) const state$ = model(intents.inputValue$, intents.messages$) return { // Send the virtual tree to the real DOM DOM: view(state$), // Send our messages to the horizon server horizon: intents.writeOps$$, } } // All the effects the app uses const drivers = { // Link the DOM driver to our app container DOM: makeDOMDriver('#app'), // Create a connection to the horizon server horizon: makeHorizonDriver(), // App-level configuration options config: () => ({ // How many chats to show chatLength: 8, // RethinkDB table collectionName: 'cyclejs_messages', // unique-ish id created once when opening the page authorId: new Date().getMilliseconds(), }), } // Run the application Cycle.run(main, drivers) // Little CycleJS driver for horizon. This will probably be a small // standalone library at some point function makeHorizonDriver() { return function horizonDriver(writeOps$$) { // Send outgoing messages writeOps$$.switch().subscribe() // Return chat observable return Horizon({ lazyWrites: true }) } } })() ================================================ FILE: examples/cyclejs-chat-app/dist/index.html ================================================
================================================ FILE: examples/express-server/main.js ================================================ #!/usr/bin/env node 'use strict' const express = require('express'); const horizon = require('@horizon/server'); const app = express(); const http_server = app.listen(8181); const options = { auth: { token_secret: 'my_super_secret_secret' } }; const horizon_server = horizon(http_server, options); console.log('Listening on port 8181.'); ================================================ FILE: examples/express-server/package.json ================================================ { "name": "express-horizon-example", "version": "0.0.1", "description": "Example of a horizon server within a express server.", "bin": "main.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "RethinkDB", "license": "MIT", "dependencies": { "@horizon/server": "^1.0.1", "express": "^4.13.3" }, "repository": { "type": "git", "url": "git+https://github.com/rethinkdb/horizon.git" } } ================================================ FILE: examples/hapi-server/main.js ================================================ #!/usr/bin/env node 'use strict' const Hapi = require('hapi'); const horizon = require('horizon-server'); const server = new Hapi.Server(); server.connection({ port: 8181 }); const http_servers = server.connections.map((c) => c.listener); const horizon_server = horizon(http_servers); server.start(() => { console.log(`Listening on port 8181.`); }); ================================================ FILE: examples/hapi-server/package.json ================================================ { "name": "hapi-horizon-example", "version": "0.0.1", "description": "Example of a horizon server within a hapi server.", "bin": "main.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "RethinkDB", "license": "MIT", "dependencies": { "horizon-server": "file:../../server", "hapi": "^12.1.0" }, "repository": { "type": "git", "url": "git+https://github.com/rethinkdb/horizon.git" } } ================================================ FILE: examples/koa-server/main.js ================================================ #!/usr/bin/env node 'use strict' const koa = require('koa'); const horizon = require('horizon-server'); const app = koa(); const http_server = app.listen(8181); const horizon_server = horizon(http_server); console.log('Listening on port 8181.'); ================================================ FILE: examples/koa-server/package.json ================================================ { "name": "koa-horizon-example", "version": "0.0.1", "description": "Example of a horizon server within a koa server.", "bin": "main.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "RethinkDB", "license": "MIT", "dependencies": { "horizon-server": "file:../../server", "koa": "^1.1.2" }, "repository": { "type": "git", "url": "git+https://github.com/rethinkdb/horizon.git" } } ================================================ FILE: examples/react-chat-app/README.md ================================================ # Horizon Chat example using React
![](https://i.imgur.com/XFostB8.gif)
## Ideas for future - [ ] implement `presence` - [ ] implement `geo` to display users differently that are geographically near you - [ ] implement `auth` to allow users to log in via Github, Twitter, etc and have their handle appear. ================================================ FILE: examples/react-chat-app/dist/app.css ================================================ /* always present */ .expand-transition { transition: all .4s ease; height: 30px; padding: 10px; background-color: #eee; overflow: hidden; } /* .expand-enter defines the starting state for entering */ /* .expand-leave defines the ending state for leaving */ .expand-enter, .expand-leave { height: 0; padding: 0 10px; opacity: 0; } #app ul { list-style-type: none; } .message { height: 50px; } .message img { vertical-align:middle; } .message .text { vertical-align:middle; margin-left:5px; font-family: 'Source Code Pro', "Raleway", "Helvetica Neue"; font-size:20px; } .message .datetime { color:darkgrey; } #input { margin-bottom:10%; } #input input { height:100px; font-size:5rem; padding:10px; font-family: 'Source Code Pro', "Raleway", "Helvetica Neue"; } ================================================ FILE: examples/react-chat-app/dist/app.jsx ================================================ /*jshint quotmark:false */ /*jshint white:false */ /*jshint trailing:false */ /*jshint newcap:false */ var app = app || {}; (function(){ 'use strict'; //Setup Horizon connection const horizon = Horizon(); app.ChatApp = React.createClass({ uuid: function () { /*jshint bitwise:false */ var i, random; var uuid = ''; for (i = 0; i < 32; i++) { random = Math.random() * 16 | 0; if (i === 8 || i === 12 || i === 16 || i === 20) { uuid += '-'; } uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random)) .toString(16); } return uuid; }, getDefaultProps: function(){ const time = new Date().getMilliseconds(); // Precache the avatar image so it immediately shows on input enter. const image = new Image(); image.src = `http://api.adorable.io/avatars/50/${time}.png`; return { horizon: horizon("react_messages"), avatarUrl: image.src, authorId: time }; }, getInitialState: function(){ return { disabled: true, messages: [] }; }, componentDidMount: function(){ // As soon as this component is mounted, enable the input this.setState({ disabled: false, }); // Initiate the changefeeds this.subscribe(); }, save: function(message){ //Save method for handling messages this.props.horizon.store({ id: this.uuid(), text: message, authorId: this.props.authorId, datetime: new Date() }).subscribe(); }, subscribe: function(){ this.props.horizon .order("datetime", "descending") .limit(8) .watch() .subscribe(messages => { this.setState({ messages: messages }) }) }, render: function(){ return (
); }, }); app.ChatList = React.createClass({ render: function(){ // Construct list of ChatMessages const messages = this.props.messages.map(function(message){ return ; }, this); // Return assembled ChatList of Messages return (
    {messages}
); } }); app.ChatMessage = React.createClass({ render: function(){ return (
  • {this.props.message.text}
  • ); } }); app.ChatInput = React.createClass({ getDefaultProps: function(){ // Set default props for enter key return { ENTER_KEY: 13 }; }, getInitialState: function(){ // Initial state of the inputText is blank "" return { inputText: "" } }, handleKeyDown: function(event){ // Checking if enter key has been pressed to handle contents of // input field value. if(event.keyCode === this.props.ENTER_KEY){ const val = this.state.inputText.trim(); if (val){ // Save the value this.props.onSave(val); // Empty the input value this.setState({inputText: ""}); } } }, handleChange: function(event){ // Every time the value of the input field changes we update the state // object to have the value of the input field. this.setState({ inputText: event.target.value }); }, render: function(){ return (
    ); } }); // Render this monster. ReactDOM.render( , document.getElementById('app') ); })(); ================================================ FILE: examples/react-chat-app/dist/index.html ================================================
    ================================================ FILE: examples/react-chat-app/dist/package.json ================================================ { "private": true, "dependencies": { "react": "^0.13.3" } } ================================================ FILE: examples/react-todo-app/.gitignore ================================================ node_modules dist/node_modules ================================================ FILE: examples/react-todo-app/dist/index.html ================================================ React • TodoMVC
    ================================================ FILE: examples/react-todo-app/dist/js/app.jsx ================================================ /*jshint quotmark:false */ /*jshint white:false */ /*jshint trailing:false */ /*jshint newcap:false */ /*global React, Router*/ var app = app || {}; (function () { 'use strict'; app.ALL_TODOS = 'all'; app.ACTIVE_TODOS = 'active'; app.COMPLETED_TODOS = 'completed'; var TodoFooter = app.TodoFooter; var TodoItem = app.TodoItem; var ENTER_KEY = 13; var TodoApp = React.createClass({ getInitialState: function () { return { nowShowing: app.ALL_TODOS, editing: null, newTodo: '' }; }, componentDidMount: function () { var setState = this.setState; var router = Router({ '/': setState.bind(this, {nowShowing: app.ALL_TODOS}), '/active': setState.bind(this, {nowShowing: app.ACTIVE_TODOS}), '/completed': setState.bind(this, {nowShowing: app.COMPLETED_TODOS}) }); router.init('/'); }, handleChange: function (event) { this.setState({newTodo: event.target.value}); }, handleNewTodoKeyDown: function (event) { if (event.keyCode !== ENTER_KEY) { return; } event.preventDefault(); var val = this.state.newTodo.trim(); if (val) { this.props.model.addTodo(val); this.setState({newTodo: ''}); } }, toggleAll: function (event) { var checked = event.target.checked; this.props.model.toggleAll(checked); }, toggle: function (todoToToggle) { this.props.model.toggle(todoToToggle); }, destroy: function (todo) { this.props.model.destroy(todo); }, edit: function (todo) { this.setState({editing: todo.id}); }, save: function (todoToSave, text) { this.props.model.save(todoToSave, text); this.setState({editing: null}); }, cancel: function () { this.setState({editing: null}); }, clearCompleted: function () { this.props.model.clearCompleted(); }, render: function () { var footer; var main; var todos = this.props.model.todos; var shownTodos = todos.filter(function (todo) { switch (this.state.nowShowing) { case app.ACTIVE_TODOS: return !todo.completed; case app.COMPLETED_TODOS: return todo.completed; default: return true; } }, this); var todoItems = shownTodos.map(function (todo) { return ( ); }, this); var activeTodoCount = todos.reduce(function (accum, todo) { return todo.completed ? accum : accum + 1; }, 0); var completedCount = todos.length - activeTodoCount; if (activeTodoCount || completedCount) { footer = ; } if (todos.length) { main = (
      {todoItems}
    ); } return (

    todos

    {main} {footer}
    ); } }); const model = new app.TodoModel("todos_react"); function render() { React.render( , document.getElementsByClassName('todoapp')[0] ); } model.subscribe(render); model.subscribeChangefeeds(); render(); })(); ================================================ FILE: examples/react-todo-app/dist/js/footer.jsx ================================================ /*jshint quotmark:false */ /*jshint white:false */ /*jshint trailing:false */ /*jshint newcap:false */ /*global React */ var app = app || {}; (function () { 'use strict'; app.TodoFooter = React.createClass({ render: function () { var activeTodoWord = app.Utils.pluralize(this.props.count, 'item'); var clearButton = null; if (this.props.completedCount > 0) { clearButton = ( ); } var nowShowing = this.props.nowShowing; return ( ); } }); })(); ================================================ FILE: examples/react-todo-app/dist/js/todoItem.jsx ================================================ /*jshint quotmark: false */ /*jshint white: false */ /*jshint trailing: false */ /*jshint newcap: false */ /*global React */ var app = app || {}; (function () { 'use strict'; var ESCAPE_KEY = 27; var ENTER_KEY = 13; app.TodoItem = React.createClass({ handleSubmit: function (event) { var val = this.state.editText.trim(); if (val) { this.props.onSave(val); this.setState({editText: val}); } else { this.props.onDestroy(); } }, handleEdit: function () { this.props.onEdit(); this.setState({editText: this.props.todo.title}); }, handleKeyDown: function (event) { if (event.which === ESCAPE_KEY) { this.setState({editText: this.props.todo.title}); this.props.onCancel(event); } else if (event.which === ENTER_KEY) { this.handleSubmit(event); } }, handleChange: function (event) { if (this.props.editing) { this.setState({editText: event.target.value}); } }, getInitialState: function () { return {editText: this.props.todo.title}; }, /** * This is a completely optional performance enhancement that you can * implement on any React component. If you were to delete this method * the app would still work correctly (and still be very performant!), we * just use it as an example of how little code it takes to get an order * of magnitude performance improvement. */ shouldComponentUpdate: function (nextProps, nextState) { return ( nextProps.todo !== this.props.todo || nextProps.editing !== this.props.editing || nextState.editText !== this.state.editText ); }, /** * Safely manipulate the DOM after updating the state when invoking * `this.props.onEdit()` in the `handleEdit` method above. * For more info refer to notes at https://facebook.github.io/react/docs/component-api.html#setstate * and https://facebook.github.io/react/docs/component-specs.html#updating-componentdidupdate */ componentDidUpdate: function (prevProps) { if (!prevProps.editing && this.props.editing) { var node = React.findDOMNode(this.refs.editField); node.focus(); node.setSelectionRange(node.value.length, node.value.length); } }, render: function () { return (
  • ); } }); })(); ================================================ FILE: examples/react-todo-app/dist/js/todoModel.js ================================================ /*jshint quotmark:false */ /*jshint white:false */ /*jshint trailing:false */ /*jshint newcap:false */ var app = app || {}; (function () { 'use strict'; const Utils = app.Utils; //Setup RethinkDB const horizon = Horizon(); // Generic "model" object. You can use whatever // framework you want. For this application it // may not even be worth separating this logic // out, but we do this to demonstrate one way to // separate out parts of your application. app.TodoModel = function (table_key) { this.todos = []; this.onChanges = []; this.todosDB = horizon(table_key); }; app.TodoModel.prototype.subscribe = function (onChange) { this.onChanges.push(onChange); }; app.TodoModel.prototype.inform = function () { this.onChanges.forEach(function (cb) { cb(); }); }; app.TodoModel.prototype.addTodo = function (title) { const newTodo = { id: Utils.uuid(), title: title, completed: false }; this.todosDB.store(newTodo); }; app.TodoModel.prototype.toggleAll = function (checked) { console.log(checked); this.todosDB.replace(this.todos.map(function (todo) { return Utils.extend({}, todo, {completed: checked}); })); }; app.TodoModel.prototype.toggle = function (todoToToggle) { console.log(todoToToggle); this.todosDB.replace( Utils.extend({}, todoToToggle, {completed: !todoToToggle.completed}) ); }; app.TodoModel.prototype.destroy = function (todo) { this.todosDB.remove(todo); }; app.TodoModel.prototype.save = function (todoToSave, text) { this.todosDB.store(Utils.extend({}, todoToSave, {title: text})); }; app.TodoModel.prototype.clearCompleted = function () { const oldTodos = this.todos.slice(); this.todos = this.todos.filter((todo) => { return !todo.completed; }); // Send batched deletion of completed todos this.todosDB.removeAll(oldTodos.filter((todo) => { return !this.todos.includes(todo); })); }; app.TodoModel.prototype.subscribeChangefeeds = function(){ this.todosDB.watch().subscribe(todos => { this.todos = todos; this.inform() }) }; })(); ================================================ FILE: examples/react-todo-app/dist/js/utils.js ================================================ var app = app || {}; (function () { 'use strict'; app.Utils = { uuid: function () { /*jshint bitwise:false */ var i, random; var uuid = ''; for (i = 0; i < 32; i++) { random = Math.random() * 16 | 0; if (i === 8 || i === 12 || i === 16 || i === 20) { uuid += '-'; } uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random)) .toString(16); } return uuid; }, pluralize: function (count, word) { return count === 1 ? word : word + 's'; }, // store: function (namespace, data) { // if (data) { // return localStorage.setItem(namespace, JSON.stringify(data)); // } // // var store = localStorage.getItem(namespace); // return (store && JSON.parse(store)) || []; // }, extend: function () { var newObj = {}; for (var i = 0; i < arguments.length; i++) { var obj = arguments[i]; for (var key in obj) { if (obj.hasOwnProperty(key)) { newObj[key] = obj[key]; } } } return newObj; } }; })(); ================================================ FILE: examples/react-todo-app/dist/package.json ================================================ { "private": true, "dependencies": { "classnames": "^2.1.5", "director": "^1.2.0", "react": "^0.13.3", "todomvc-app-css": "^2.0.0", "todomvc-common": "^1.0.1" } } ================================================ FILE: examples/react-todo-app/readme.md ================================================ # React TodoMVC example using React ## Ideas for future - [ ] implement `presence` for other connected people ToDo-ing ### Credit Thanks to @todomvc for the original repo this was forked from. ================================================ FILE: examples/riotjs-chat-app/dist/app.css ================================================ /* always present */ /*.expand-transition { transition: all .2s ease; height: 30px; padding: 10px; background-color: #eee; overflow: hidden; }*/ /* .expand-enter defines the starting state for entering */ /* .expand-leave defines the ending state for leaving */ /*.expand-enter, .expand-leave { height: 0; padding: 0 10px; opacity: 0; }*/ .messages ul { list-style-type: none; } .message { height: 50px; } .message img { vertical-align:middle; } .message .text { vertical-align:middle; margin-left:5px; font-family: 'Source Code Pro', "Raleway", "Helvetica Neue"; font-size:20px; } .message .datetime { color:darkgrey; } .input { margin-bottom:10%; } .input input { top: 80%; height:100px; font-size:5rem; padding:10px; font-family: 'Source Code Pro', "Raleway", "Helvetica Neue"; } ================================================ FILE: examples/riotjs-chat-app/dist/chat.tag ================================================
    • { text } { datetime.toTimeString() }
    ================================================ FILE: examples/riotjs-chat-app/dist/index.html ================================================ ================================================ FILE: examples/vue-chat-app/.gitignore ================================================ node_modules dist/node_modules ================================================ FILE: examples/vue-chat-app/README.md ================================================ # RethinkDB Horizon Chat example using Vue.js
    ![](https://i.imgur.com/XFostB8.gif)
    - [ ] implement `presence` - [ ] implement `geo` to display users differently that are geographically near you - [ ] implement `auth` to allow users to log in via Github, Twitter, etc and have their handle appear. ================================================ FILE: examples/vue-chat-app/dist/app.css ================================================ /* always present */ .expand-transition { transition: all .2s ease; height: 30px; padding: 10px; background-color: #eee; overflow: hidden; } /* .expand-enter defines the starting state for entering */ /* .expand-leave defines the ending state for leaving */ .expand-enter, .expand-leave { height: 0; padding: 0 10px; opacity: 0; } #app ul { list-style-type: none; } .message { height: 50px; } .message img { vertical-align:middle; } .message .text { vertical-align:middle; margin-left:5px; font-family: 'Source Code Pro', "Raleway", "Helvetica Neue"; font-size:20px; } .message .datetime { color:darkgrey; } #input { margin-bottom:10%; } #input input { position: fixed; top: 80%; width: 80%; height:100px; font-size:5rem; padding:10px; font-family: 'Source Code Pro', "Raleway", "Helvetica Neue"; } ================================================ FILE: examples/vue-chat-app/dist/app.js ================================================ 'use strict' const horizon = Horizon(); const chat = horizon('chat') const app = new Vue({ el: '#app', data: { newMessage: '', avatar_url: `http://api.adorable.io/avatars/50/${new Date().getMilliseconds()}.png`, messages: [], }, methods: { addMessage: function() { var text = this.newMessage.trim(); if (text) { chat.store({ text: text, datetime: new Date(), url: this.avatar_url, }).subscribe(); this.newMessage = ''; } }, messagesUpdate: function(newMessages) { this.messages = newMessages; console.log(this.messages); }, }, }); chat.order('datetime', 'descending') .limit(8) .watch() .subscribe(app.messagesUpdate); // Image preloading const image = new Image(); image.src = app.avatar_url; ================================================ FILE: examples/vue-chat-app/dist/index.html ================================================
    • {{ message.text }} {{message.datetime.toTimeString()}}
    ================================================ FILE: examples/vue-todo-app/.gitignore ================================================ dist/node_modules node_modules ================================================ FILE: examples/vue-todo-app/dist/index.html ================================================ Vue.js • TodoMVC

    fused todos

    ================================================ FILE: examples/vue-todo-app/dist/js/app.js ================================================ /*global Vue, todoStorage */ (function (exports) { 'use strict'; var filters = { all: function (todos) { return todos; }, active: function (todos) { return todos.filter(function (todo) { return !todo.completed; }); }, completed: function (todos) { return todos.filter(function (todo) { return todo.completed; }); } }; exports.app = new Vue({ // the root element that will be compiled el: '.todoapp', debug: true, // app initial state data: { todos: [], newTodo: '', editedTodo: null, visibility: 'all' }, // watch todos change for localStorage persistence // watch: { // // todos: { // // deep: false, // // handler: todoStorage.save, // // }, // }, // computed properties // http://vuejs.org/guide/computed.html computed: { filteredTodos: function () { return filters[this.visibility](this.todos); }, remaining: function () { return filters.active(this.todos).length; }, allDone: { get: function () { return this.remaining === 0; }, set: function (value) { this.todos.forEach(function (todo) { todo.completed = value; this.updateTodo(todo); }.bind(this)); } }, }, // methods that implement data logic. // note there's no DOM manipulation here at all. methods: { addTodo: function () { const value = this.newTodo && this.newTodo.trim(); if (!value) { return; } const todo = { title: value, completed: false, datetime: new Date(), }; todoStorage.save(todo); this.newTodo = ''; }, removeTodo: function (todo){ todoStorage.remove(todo); }, editTodo: function (todo) { this.beforeEditCache = todo.title; this.editedTodo = todo; this.updateTodo(todo); }, doneEdit: function (todo) { if (!this.editedTodo) { return; } this.editedTodo = null; todo.title = todo.title.trim(); if (!todo.title) { this.removeTodo(todo); } }, updateTodo: function(todo){ todoStorage.update(todo); }, cancelEdit: function (todo) { this.editedTodo = null; todo.title = this.beforeEditCache; }, removeCompleted: function () { filters.completed(this.todos).forEach(this.removeTodo); }, }, // a custom directive to wait for the DOM to be updated // before focusing on the input field. // http://vuejs.org/guide/custom-directive.html directives: { 'todo-focus': function (value) { if (!value) { return; } var el = this.el; Vue.nextTick(function () { el.focus(); }); } } }); todoStorage.changes().subscribe(todos => exports.app.todos = todos) })(window); ================================================ FILE: examples/vue-todo-app/dist/js/routes.js ================================================ /*global app, Router */ (function (app, Router) { 'use strict'; var router = new Router(); ['all', 'active', 'completed'].forEach(function (visibility) { router.on(visibility, function () { app.visibility = visibility; }); }); router.configure({ notfound: function () { window.location.hash = ''; app.visibility = 'all'; } }); router.init(); })(app, Router); ================================================ FILE: examples/vue-todo-app/dist/js/store.js ================================================ /*jshint unused:false */ (function(exports) { 'use strict'; const horizon = Horizon(); const todos = horizon("vuejs_todos"); exports.todoStorage = { todos: todos, save: function(newVal) { todos.store(newVal); }, update: function(todo) { todos.replace({ id: todo.id, title: todo.title, completed: todo.completed, datetime: todo.datetime, }) }, remove: function(todo) { todos.remove(todo); }, changes: function() { return todos.watch() } }; })(window); ================================================ FILE: examples/vue-todo-app/dist/package.json ================================================ { "private": true, "dependencies": { "director": "^1.2.0", "vue": "^1.0.1", "todomvc-common": "^1.0.1", "todomvc-app-css": "^2.0.0" } } ================================================ FILE: examples/vue-todo-app/readme.md ================================================ # RethinkDB Horizon TodoMVC example using Vue.js ## Ideas for future - [ ] implement `presence` for other connected people ToDo-ing ## Credit This TodoMVC application was originally created by [Evan You](http://evanyou.me). ================================================ FILE: protocol.md ================================================ ### Handshake The handshake is required before any requests can be served. If the first message sent cannot be parsed as a handshake, the connection will be dropped. The handshake will be used to associate the client with a specific user (and set of security rules) on the server. This should be extensible in the same way as #12. For now let's just leave this a placeholder, since we haven't gotten to authentication yet. #### Handshake Request ``` { "request_id": , "method": "unauthenticated" | "anonymous" | "token", "token": , } ``` * `request_id` is a number uniquely identifying this request, it will be returned in the response. * `method` designates the type of authentication to be performed. * `unauthenticated` performs no further steps and will not associate the connection with any user. * `anonymous` will create a new account with no external authentication provider. * `token` will associate the connection with the user in the horizon access token provided. * `token` is the horizon access token that the client must already possess. * This field is required when `method` is `token`, and invalid otherwise. #### Handshake Response ``` { "request_id": , "token": } ``` * `token` is the horizon access token that is associated with this connection. * This token may be used to establish new connections under the same user account until the token expires. #### Handshake Error Response ``` { "request_id": , "error": , "error_code": } ``` ### Requests All requests match the following pattern: ``` { "request_id": , "type": , "options": } ``` * `request_id` is a number uniquely identifying this request, it will be returned in any responses * `type` is the endpoint for the query - one of `query`, `subscribe`, `store_error`, `store_replace`, `update`, or `remove`. * `options` is an object structured differently for each endpoint. #### query, subscribe ``` { "request_id": , "type": "query" | "subscribe", "options": { "collection": , "order": [ , "ascending" | "descending"], "above": [ , "open" | "closed" ], "below": [ , "open" | "closed" ], "find": , "find_all": [, ...], "limit": , } } ``` * `collection` describes which table to operate on in the horizon database. * `order` orders the results according to an array of fields - optional. * The first argument is an array of field names, most-significant first. * The second argument determines which direction the results are sorted in. * `above` and `below` are arrays describing the boundaries regarding `order` - optional. * `above` and `below` can only be specified if `order` is provided. * The first argument is an object whose key-value pairs correspond to fields in `order`. * The second argument should be `closed` to include the boundary, and `open` otherwise. * `find` returns one object in `collection` that exactly matches the fields in the object given - optional. * `find` cannot be used with `find_all`, `order`, `above`, or `below`. * `find_all` is an array of objects whose key-value pairs correspond to keys in `index` - optional. * Returns any object in `collection` that exactly matches the fields in any of the objects given. * `find_all` cannot be used with `find`. * `find_all` with multiple objects cannot be used with `order`, `above`, or `below`. * `limit` limits the number of results to be selected - optional. #### insert, store, upsert, replace, update, remove ``` { "request_id": , "type": "store" | "update" | "upsert" | "insert" | "replace" | "remove", "options": { "collection": , "data": [, ... ] } } ``` * `collection` describes which table to operate on in the horizon database * `data` is the documents to be written (or removed) * `data[i].id` is required for `remove` operations, all other fields are optional * `data[i].id` may be omitted in an `insert`, `store`, or `upsert` operations: a new row will be inserted in the collection * `type` is the write operation to perform * `insert` inserts new documents, erroring if any document already exists * `update` updates existing documents. It errors if any document does not already exist * `upsert` updates existing documents or inserts them if they do not exist * `replace` replaces existing documents entirely. It errors if any document does not already exist * `store` replaces existing documents entirely, or inserts them if they don't exist. * `remove` removes documents. It will not error if a document does not exist #### end_subscription Tells the horizon server to stop sending data for a given subscription. Data may still be received until the server has processed this and sent a `"state": "complete"` response for the subscription. ``` { "request_id": , "type": "end_subscription" } ``` #### Keepalive This is used by the client to perform an empty request to avoid connection interruption. ``` { "request_id": , "type": "keepalive" } ``` ### Responses #### Error Response This can be sent for any request at any time. Once an error response is sent, no further responses shall be sent for the corresponding `request_id`. ``` { "request_id": , "error": , "error_code": } ``` * `request_id` is the same as the `request_id` in the corresponding request * `error` is a descriptive error string * `error_code` is a code that can be used to identify the type of error, values TBD #### query, subscribe `query` and `subscribe` requests will result in a stream of results from the horizon server to the client. The stream will be an ordered set of messages from the server following the structure below. If an error occurs, the above Error Response structure will be used, and the stream is considered "complete". An Error Response may still be sent even after a successful data response, but not after `"state": "complete"`. ``` { "request_id": , "data": , "state": "synced" | "complete" } ``` * `request_id` is the same as the `request_id` in the corresponding request * `data` is an array of results for the `query` or `subscribe`, and may be empty * `state` is optional, and indicates a change in the stream of data: * `synced` means that following the consumption of `data`, the client has all the initial results of a `subscribe` * `complete` means that following the consumption of `data`, no more results will be returned for the request #### Write responses `store`, `replace`, `insert`, `update`, `upsert`, and `remove` requests will be given a single response. This may be an Error Response, or: ``` { "request_id": , "data": [ { "id": , "$hz_v$": } | { "error": , "error_code": }, ...], "state": "complete" } ``` * `data` is an array of objects corresponding to the documents specified in the write (whether or not a change occurred). For inserted documents it will be the id generated by the server as well as the latest version field for the affected document. If an error occurred, there will instead be an error description string and an error code in the object . The items in the array correspond directly to the changes in the request, in the same order. * `state` can only be "complete" for write responses #### Keepalive `keepalive` requests will be given a single response. This will never be an error response unless there is a protocol error. ``` { "request_id": , "state": "complete" } ``` ================================================ FILE: rfcs/identity_mgmt.md ================================================ # Identity Management ### Related issue: rethinkdb/horizon#3 ### Problem Horizon needs a concept of a user, and clients of a Horizon server need to be able to add, delete and list users. Users are a fundamental entity needed for both the authentication system and permission system. App developers may also want to add additional meaning to users depending on their needs. ### Proposed solution Create a virtual collection called `users` which is really a view into the internal Horizon users table. All operations should transparently operate on a subdocument in the real table. #### Client changes No client changes necessary. There will simply appear to be an ordinary collection in new apps called `users`. Examples: ```js horizon('users').find(userId) horizon('users').findAll({first_name: "John", last_name: "Smith"}) ``` #### Protocol changes No protocol changes. #### Server changes A `horizon_internal.users` table exists internally right now for authentication. We should continue to use this to track users, but should add a sub-document with the key `app_data`. When a query or write operation from the client refers to the `users` collection, it instead should operate on the the `app_data` subdocument. Example: Actual document in `horizon_internal.users`: ```json { "id": 23, "permissions": [], "app_data": { "foo": "bar" } } ``` Document returned when client requests user 23: ```json { "id": 23, "foo": "bar" } ``` Resulting document after `horizon('users').update({id: 23, age: 65})` ``` json { "id": 23, "permissions": [], "app_data": { "foo": "bar", "age": 65 } } ``` Additionally, queries for users will use secondary indexes on the nested `app_data` document, but queries for users by id will use the primary key of the document. Example: A client query like: Client query: ```js horizon('users').findAll({age: 65}).fetch() ``` Backend: ```js // index creation r.db('horizon_internal').table('users').indexCreate('app_data_age', x => x('app_data')('age')) // query r.db('horizon_internal').table('users').getAll(65, {index: 'app_data_age'}) ``` ================================================ FILE: rfcs/permissions.md ================================================ # Permissions ## Description [Related issue (#4)](https://github.com/rethinkdb/horizon/issues/4) Any real application needs to be able to restrict what operations users are able to perform on the database. Horizon needs a way to specify and enforce these kinds of permissions. ## Proposed solution At a high level, we are adding three new things: 1. **Groups** which users can belong to and own. It's also possible for a group to own another group. There will be new query types specifically for interacting with groups. 2. **Query template rules** which restrict the kinds of queries that can be run by a user. These are very performant since we can reject bad operations before a query is even executed. 3. **Arbitrary js rules** which restrict the results that can be returned from a query. They are specified with a pure javascript function, and are more flexible than query templates (at the cost of being potentially very slow). These are intended as an escape hatch when query templates aren't able to enforce the desired security rule. Permissions will be specified in the `hzapp.config` file (which is where app-level configuration like table names and secondary indexes are defined). You may also specify hardcoded users and groups in the config as well. ## Groups * Each group has either an `owner` or an `owning_group`, as well as a list of members. * An `owner` can delete the group and add members to the group. * If the group has an `owning_group` instead of an `owner`, any member of the `owning_group` may delete the owned group as well as add and remove members from the owned group. * If a user is the `owner` of a group, she is automatically a member of the group. * However, members of an `owning_group` are not considered members of the owned group unless explicitly placed into the group. * The `owning_group` can be the group itself. This is useful for creating administrator groups. * Group ownership is not transitive. That is, if group `A` is the `owning_group` of group `B`, and group `B` is the `owning_group` for group `C`, members of group `A` are **not** considered owners of group `C`, even though members of group `B` are. This is to simplify logic for checking ownership and circularity. * The `owner` of a group cannot be removed from the group without replacing the owner, or changing ownership to another group. This is intended to prevent groups from being impossible to administrate. * A group cannot be named `"ANYONE"`. ### Config file changes for groups The config file gains two new sections related to groups: `initial.users` contains one section for each user that should be created when the app is set up for the first time. These users are considered "hard coded" and can be assumed to be present in client code. The keys in each user's section will be placed in the appData document for that user (see the [RFC on users](https://github.com/rethinkdb/horizon/pull/151)). If there is no appData for that user, the section can be empty. The purpose of this section is mainly useful for hard-coding administrative users. `initial.groups` contains one section for each new group. Each group must have either an `owner` key, or an `owning_group` key. The `owner` and `owning_group` values may only refer to groups and users specified in the `initial.groups` and `initial.users` section. While groups can be created dynamically (see the client section below), it's also perfectly possible that an app may create all of its groups up front and forbid new group creation outright. Example: ```toml [initial.user.admin] foo = 'bar' [initial.user.superadmin] [initial.user.dalanmiller] [initial.user.deontologician] [initial.user.tryneus] [initial.groups.administrators] owning_group = 'administrators' members = [ 'superadmin', 'admin' ] [initial.groups.users] owning_group = 'administrators' members = [ 'dalanmiller', 'deontologician', 'tryneus', ] ``` ### Client changes for groups The Horizon client will gain a new `group` method at the same level as collections, and supporting all the same operations as `.get`, with restrictions on how the document can be manipulated. `horizon.groups()` will refer to the "collection" of groups. It lives in a different namespace from normal collections because the server enforces some rules for operating on documents in the table. A group document has the keys: - `id`: contains the name of the group (referred to as `groupName` in this rfc) - `owner`/`owning_group` with the `userId`/`groupName` that owns it All of the write operations allowed on normal documents are allowed on `.groups()`: - `horizon.groups().[insert|store|upsert|replace|remove]`. These have their normal semantics, but will fail if any keys other than `id`, `owner` or `owning_group` are added, or if they are not valid (for instance, if they don't refer to a real user or group). - these operations are how groups are created, deleted, and ownership is changed. - if `insert`ing a group, and no `owner`/`owning_group` is set, the insert will fail. - `horizon.groups().[find|findAll|above|below|order|limit]` have their normal semantics, except on groups. - `horizon.groups().find(groupName).add(userId)` will add a user to a group. - `horizon.groups().find(groupName).remove(userId)` will remove a user from a group. This is different from `horizon.groups().remove(groupName)` which will delete the group itself. - `horizon.groups().find(groupName).members()` will list all members of the group. Documents will look like: ``` { "id": , "owner" | "member": , } ``` Since members of an `owning_group` are not considered members of the owned group, they will not be returned in the results of a `members()` query. ### Protocol changes for groups The write operations (`insert`, `update`, `upsert`, `replace`, `store`, `remove`), as well as the read operations (`query`, `subscribe`) will now allow the field `group_name` to be given anywhere `collection` was accepted before. `group_name` and `collection` are mutually exclusive. Additionally, when the request type is `query` or `subscribe`, and the `options` object has the `find` key set, the key `members` may be set to `null`. There is a new request type `group_members`, which enables the `add` and `remove` operations on groups. When the the type is `group_members`, the `options` key must contain the key `group_name` with the name of the group, and either: - `add` with an array of userIds to add to the group; or - `remove` with an array of userIds to remove from the group Examples: `horizon.groups().find(groupName).members()` translates to: ``` { "request_id": , "type": "query", "options": { "group_name": , "members": null } } ``` `horizon.groups().findAll({owner: userId}).limit(3)` translates to: ``` { "request_id": , "type": "query", "options": { "group_name": null, "findAll": [{owner: }], "limit": 3 } } ``` `horizon.groups().find(groupName).add(userId)` translates to: ``` { "request_id": , "type": "group_members", "options": { "group_name": ,' "add": [, ...], } } ``` `horizon.groups().remove(groupName)` translates to: ``` { "request_id": , "type": "remove", "options": { "group_name": , "data": [, ...] } } ``` ### Server changes for groups The server will create two new internal tables: `groups` and `group_members`. `groups` will have the schema: ``` { "id": , "owner" | "owning_group": | } ``` The `group_members` table will have the schema: ``` { "id": [, ] } ``` There should be a secondary index on the user id, so groups can be looked up for a user. When creating a group, the document should first be inserted into the `groups` table with the intended `owner`, then a member entry should be added to the `group_membership` table. This allows the groups table to become inconsistent if a crash in the server or client happens between the `groups` insert and the `group_members` insert. In the future, we should solve this with true two-phase commits, but for now it's a rare enough problem and is mainly an inconvenience (since the group name will just become unusable). Additional things the server needs to do: * ensure that inserts into the `group_members` table are valid user ids from the `users` table. * ensure that when setting `owning_group`, the value is either the name of the current group, or is the name of an existing group. * ensure that when a request operates on a group, that the user is either the owner of that group, or the user is a member of the `owning_group`. * ensure that the current owner of a group cannot be removed as a member of the group. * ensure no group can be created with the name `ANYONE` ## Query template rules Query templates are a white list of the shapes of queries that can be executed by users. Rules can be enabled for certain groups, or for all groups. ### Config changes for query template rules Templates are specified in the `hzapp.config` file in the section `[query_whitelist]` There are 2 special variables: `USER` and `ANYTHING`. - `USER` stands in for the current user. It has several properties: - `id` the username/primary key of the user - `groups` a set of groups the user belongs to - `groups_owned` a set of groups the user owns (either directly, or by being a member of an `owning_group`) - `appData` an object with data created and maintained by the app. If users have access to write to their own document, rules based on this data may be insecure. - `ANYTHING` is a don't care value. This is to explicitly declare any value is acceptable in a query. In the section `[query_whitelist]` each key specifies the group that the specified queries are whitelisted for. They can't refer to groups created dynamically by the application. There is a special group `ANYONE` that indicates the specified queries are executable by any user, even ones that are anonymous or unauthenticated. As for syntax: * The queries can start with `horizon` or `hz` before the parenthesis * The queries should be valid javascript syntax. Example: ```toml [query_whitelist] ANYONE = [ "horizon('messages').findAll({to: USER.id})", "horizon('messages').findAll({from: USER.id})", "horizon('broadcasts').findAll({to_group: USER.groups, from: ANYTHING})", ] admin = [ "horizon('messages').findAll({})", ] ``` This would allow a user to retrieve all messages they have either sent or received, as well as any broadcasts to any group the user is a member of. (Note that `to_group: USER.groups` is not interpreted as equality, it's translated implicitly to 'the `to_group` field matches any elements of `USER.groups`). This would also allow any members of the `admin` group to run queries for all messages. #### Subsets of whitelisted queries are ok Extensions of whitelisted queries are allowed without explicitly stating them. N.B. This takes advantage of the fact that chaining more operations onto a Horizon query currently always returns fewer results. If this assumption is violated in the future, implicit whitelisting described in this section will not be safe. Example: If there is a template: ```toml [query_whitelist] ANYONE = [ "horizon('A').findAll({owner: USER.id})", "horizon('B')" ] ``` Then all of these queries are legal: - `horizon('A').findAll({owner: userId})` - `horizon('A').findAll({owner: userId}).above({date: Date.now()})` - `horizon('A').findAll({owner: userId, type: 'car'})` - `horizon('B')` - `horizon('B').findAll({category: 'cars'})` - `horizon('B').findAll({category: 'cars'}).above({date: tomorrow})` #### Specifying write operations Templates for write operations can restrict certain fields of documents being stored. Implicitly, any fields not mentioned in the template are free to be whatever the user wants. Example: ```toml [query_whitelist] ANYONE = [ "horizon('messages').insert({from: USER.id, to: USER.groups})" ] ``` The above rule would allow this query for a user in the `players` group: - `horizon('messages').insert({from: userId, to: 'players', msg: 'Hey there!'})` ### Server changes for query template rules The server will need to be able to eval the whitelist rules and store a representation of them that makes it easy to quickly validate an incoming query. The server will need to create a few changefeeds per user: - A changefeed on the user's document itself. This is to keep an up-to-date view of the user's `appData` object for evaluating rules. This may be skipped if none of the rules make use of a user's `appData` field. - A changefeed on the `group` internal table. This is to keep track of which groups a user is the owner of. - A changefeed on the `group_membership` table to keep track of which groups a user is a member of. The server should be able to infer from this data which groups the user is a member of `owning_group` for. Crucially, it should reject queries that do not match one of the whitelisted rules for the user's groups. ## Arbitrary js rules Arbitrary js rules are specified in the `[js_blacklist]` section of `hzapp.config`. Each rule has a name as a key, which can be returned in errors to the client indicating which rule was violated. ### Config changes for arbitrary js rules #### Read rules Users provide an arbitrary JS function that takes as an input the current user (including attributes on the `USER` object above), and the document they're trying to read from the database. It returns whether or not they're allowed to read it as a boolean. These rules go in the `[js_blacklist]` section. As with query templates, the group or `ANYONE` should be specified to narrow the scope of where the rule applies. Example: ```toml [js_blacklist.no_old_docs] applies_to = 'ANYONE' for = 'reads' collection = 'aged_documents' js = """ function(user, document) { if (document.age > user.appData.maxDocAge) { return false; } else { return true; } } """ ``` - `applies_to` specifies this rule applies to anyone, this can be any group name or 'ANYONE' - `for` can be either `reads` or `writes` - `collection` is an optional field specifying a collection the rule should apply to. This can also be an array of collection names. If not present, the rule applies globally. - `js` contains the actual function definition When an error is raised, it should mention that the rule `no_old_docs` was violated for the query. N.B. Arbitrary document ages are a very inefficient way to enforce document schemas. They aren't intended to be used for that purpose. #### Write rules Write rules are similar to read rules, except that they get both the previous version of the document and the new version of the document. These rules go in the `[js_blacklist.writes]` section. Example: Suppose for a group called `users` we have the following rule: ```toml [js_blacklist.no_changing_ownership] for = 'writes' group = 'ANYONE' js = """ function(user, oldDocument, newDocument) { if (oldDocument.owner !== newDocument.owner) { return false; } else { return true; } } """ ``` This rule would disallow changing the ownership of any document in any collection. #### Errors in batch writes When an individual write in a batch write fails an arbitrary js check, the entire batch won't fail. Instead that document will be skipped, and the server will attempt to continue on for the rest of the documents in the batch. ### Server changes for arbitrary js rules The server will need to read in the permissions from the `hzapp.config` file, and enforce them as described above. ================================================ FILE: server/.babelrc ================================================ { "presets": ["es2015"] } ================================================ FILE: server/.eslintrc.js ================================================ const OFF = 0; const WARN = 1; const ERROR = 2; module.exports = { extends: "../.eslintrc.js", rules: { "max-len": [ ERROR, 130 ], "prefer-template": [ OFF ], }, env: { "es6": true, "node": true, "mocha": true, }, }; ================================================ FILE: server/.gitignore ================================================ cert.pem key.pem node_modules *.log # Coverage directory used by tools like istanbul coverage # RethinkDB stuff rethinkdb_data/ rethinkdb_data_test/ ================================================ FILE: server/README.md ================================================ # Horizon Server An extensible middleware server built on top of [RethinkDB](https://github.com/rethinkdb/rethinkdb) which exposes a websocket API to front-end applications. ## Documentation Follow our documentation at [samuelhughes.com/rethinkdb/horizon-docs/install.html](http://samuelhughes.com/rethinkdb/horizon-docs/install.html) for instructions on installing Horizon. ## Requirements The Horizon server requires some tools and libraries to be available before it can run: * `node.js` - interpreter to run the Horizon server * `openssl` - generating ssl certificates * [`rethinkdb`](https://github.com/rethinkdb/rethinkdb) - for running a RethinkDB server ### OpenSSL OpenSSL is required to generate the cert and key pair necessary to serve Horizon securely via HTTPS and WSS. Usually this is done on the production server where you are running Horizon, however to do this locally you'll need to have the OpenSSL installed. * **OSX** - `brew install openssl` * **Ubuntu** - [Follow this guide here](https://help.ubuntu.com/community/OpenSSL#Practical_OpenSSL_Usage). * **Windows** - [Unofficial list of Windows OpenSSL Binaries](https://wiki.openssl.org/index.php/Binaries) ### RethinkDB Check out [rethinkdb.com/install](https://rethinkdb.com/install) for the best method of installing RethinkDB on your platform. ================================================ FILE: server/package.json ================================================ { "name": "@horizon/server", "version": "2.0.0", "description": "Server for RethinkDB Horizon, an open-source developer platform for building realtime, scalable web apps.", "main": "src/horizon.js", "scripts": { "lint": "eslint src test", "test": "mocha test/test.js test/schema.js --timeout 10000", "coverage": "istanbul cover _mocha test/test.js" }, "repository": { "type": "git", "url": "git+https://github.com/rethinkdb/horizon.git" }, "author": "RethinkDB", "license": "MIT", "bugs": { "url": "https://github.com/rethinkdb/horizon/issues" }, "homepage": "https://github.com/rethinkdb/horizon#readme", "engines": { "node": ">=4.0.0" }, "dependencies": { "@horizon/client": "2.0.0", "bluebird": "^3.4.0", "cookie": "^0.2.3", "joi": "^8.0.4", "jsonwebtoken": "^5.5.4", "mime-types": "^2.0.4", "oauth": "^0.9.14", "pem": "^1.8.1", "rethinkdb": "^2.1.1", "winston": "^2.1.0", "ws": "^1.1.0" }, "devDependencies": { "eslint": "^7.3.1", "istanbul": "^0.4.3", "mocha": "^2.3.3", "nodemon": "^1.8.1" } } ================================================ FILE: server/src/auth/auth0.js ================================================ 'use strict'; const auth_utils = require('./utils'); const logger = require('../logger'); const https = require('https'); const querystring = require('querystring'); const url = require('url'); const Joi = require('joi'); const options_schema = Joi.object().keys({ path: Joi.string().required(), id: Joi.string().required(), secret: Joi.string().required(), host: Joi.string().required(), }).unknown(false); function auth0(horizon, raw_options) { const options = Joi.attempt(raw_options, options_schema); const client_id = options.id; const client_secret = options.secret; const host = options.host; const self_url = (self_host, path) => url.format({ protocol: 'https', host: self_host, pathname: path }); const make_acquire_url = (state, redirect_uri) => url.format({ protocol: 'https', host: host, pathname: '/authorize', query: { response_type: 'code', client_id, redirect_uri, state } }); const make_token_request = (code, redirect_uri) => { const req = https.request({ method: 'POST', host, path: '/oauth/token', headers: { 'Content-type': 'application/x-www-form-urlencoded' } }); req.write(querystring.stringify({ client_id, redirect_uri, client_secret, code, grant_type: 'authorization_code' })); return req; }; const make_inspect_request = (access_token) => https.request({ host, path: '/userinfo', headers: { Authorization: `Bearer ${access_token}` } }); const extract_id = (user_info) => user_info && user_info.user_id; auth_utils.oauth2({ horizon, provider: options.path, make_acquire_url, make_token_request, make_inspect_request, extract_id, }); } module.exports = auth0; ================================================ FILE: server/src/auth/facebook.js ================================================ 'use strict'; const logger = require('../logger'); const auth_utils = require('./utils'); const https = require('https'); const Joi = require('joi'); const querystring = require('querystring'); const url = require('url'); const options_schema = Joi.object().keys({ path: Joi.string().required(), id: Joi.string().required(), secret: Joi.string().required(), }).unknown(false); function facebook(horizon, raw_options) { const options = Joi.attempt(raw_options, options_schema); const client_id = options.id; const client_secret = options.secret; const provider = options.path; // Facebook requires inspect requests to use a separate app access token let app_token; const make_app_token_request = () => https.request( url.format({ protocol: 'https', host: 'graph.facebook.com', pathname: '/oauth/access_token', query: { client_id, client_secret, grant_type: 'client_credentials' } })); auth_utils.run_request(make_app_token_request(), (err, body) => { const parsed = body && querystring.parse(body); app_token = parsed && parsed.access_token; if (err) { logger.error(`Failed to obtain "${provider}" app token: ${err}`); } else if (!app_token) { logger.error(`Could not parse access token from API response: ${body}`); } }); const oauth_options = { horizon, provider }; oauth_options.make_acquire_url = (state, redirect_uri) => url.format({ protocol: 'https', host: 'www.facebook.com', pathname: '/dialog/oauth', query: { client_id, state, redirect_uri, response_type: 'code' } }); oauth_options.make_token_request = (code, redirect_uri) => { const req = https.request({ method: 'POST', host: 'graph.facebook.com', path: '/v2.3/oauth/access_token' }); req.write(querystring.stringify({ code, redirect_uri, client_id, client_secret })); return req; }; oauth_options.make_inspect_request = (input_token) => https.request( url.format({ protocol: 'https', host: 'graph.facebook.com', pathname: '/debug_token', query: { access_token: app_token, input_token } })); oauth_options.extract_id = (user_info) => user_info && user_info.data && user_info.data.user_id; auth_utils.oauth2(oauth_options); } module.exports = facebook; ================================================ FILE: server/src/auth/github.js ================================================ 'use strict'; const auth_utils = require('./utils'); const https = require('https'); const Joi = require('joi'); const querystring = require('querystring'); const url = require('url'); const options_schema = Joi.object().keys({ path: Joi.string().required(), id: Joi.string().required(), secret: Joi.string().required(), }).unknown(false); function github(horizon, raw_options) { const options = Joi.attempt(raw_options, options_schema); const client_id = options.id; const client_secret = options.secret; const provider = options.path; const oauth_options = { horizon, provider }; oauth_options.make_acquire_url = (state, redirect_uri) => url.format({ protocol: 'https', host: 'github.com', pathname: '/login/oauth/authorize', query: { client_id, redirect_uri, state } }); oauth_options.make_token_request = (code, redirect_uri) => { const req = https.request({ method: 'POST', host: 'github.com', path: '/login/oauth/access_token', headers: { accept: 'application/json' } }); req.write(querystring.stringify({ code, client_id, client_secret, redirect_uri })); return req; }; oauth_options.make_inspect_request = (access_token) => https.request({ host: 'api.github.com', path: `/user?${querystring.stringify({ access_token })}`, headers: { 'user-agent': 'node.js' } }); oauth_options.extract_id = (user_info) => user_info && user_info.id; auth_utils.oauth2(oauth_options); } module.exports = github; ================================================ FILE: server/src/auth/google.js ================================================ 'use strict'; const logger = require('../logger'); const auth_utils = require('./utils'); const https = require('https'); const Joi = require('joi'); const querystring = require('querystring'); const url = require('url'); const options_schema = Joi.object().keys({ path: Joi.string().required(), id: Joi.string().required(), secret: Joi.string().required(), }).unknown(false); function google(horizon, raw_options) { const options = Joi.attempt(raw_options, options_schema); const client_id = options.id; const client_secret = options.secret; const provider = options.path; const oauth_options = { horizon, provider }; oauth_options.make_acquire_url = (state, redirect_uri) => url.format({ protocol: 'https', host: 'accounts.google.com', pathname: '/o/oauth2/v2/auth', query: { client_id, redirect_uri, state, response_type: 'code', scope: 'profile' } }); oauth_options.make_token_request = (code, redirect_uri) => { const query_params = querystring.stringify({ code, client_id, client_secret, redirect_uri, grant_type: 'authorization_code' }); const path = `/oauth2/v4/token?${query_params}`; return https.request({ method: 'POST', host: 'www.googleapis.com', path }); }; oauth_options.make_inspect_request = (access_token) => { logger.debug(`using access token: ${access_token}`); const path = `/oauth2/v1/userinfo?${querystring.stringify({ access_token })}`; return https.request({ host: 'www.googleapis.com', path }); }; oauth_options.extract_id = (user_info) => user_info && user_info.id; auth_utils.oauth2(oauth_options); } module.exports = google; ================================================ FILE: server/src/auth/slack.js ================================================ 'use strict'; const auth_utils = require('./utils'); const https = require('https'); const Joi = require('joi'); const querystring = require('querystring'); const url = require('url'); const options_schema = Joi.object().keys({ path: Joi.string().required(), id: Joi.string().required(), secret: Joi.string().required(), }).unknown(false); function slack(horizon, raw_options) { const options = Joi.attempt(raw_options, options_schema); const client_id = options.id; const client_secret = options.secret; const provider = options.path; const scope = options && options.scope || 'identify'; const team = options && options.team || ''; const oauth_options = { horizon, provider, }; oauth_options.make_acquire_url = (state, redirect_uri) => url.format({ protocol: 'https', host: 'slack.com', pathname: '/oauth/authorize', query: { client_id, redirect_uri, state, scope, team, }, }); oauth_options.make_token_request = (code, redirect_uri) => https.request({ method: 'POST', host: 'slack.com', path: `/api/oauth.access?${querystring.stringify({ code, client_id, client_secret, redirect_uri, })}`, headers: { 'Content-Type': 'application/json', accept: 'application/json', }, }); oauth_options.make_inspect_request = (access_token) => https.request({ host: 'slack.com', path: `/api/auth.test?${querystring.stringify({ token: access_token })}`, headers: { 'Content-Type': 'application/json', 'user-agent': 'node.js', }, }); oauth_options.extract_id = (user_info) => user_info && user_info.user_id; auth_utils.oauth2(oauth_options); } module.exports = slack; ================================================ FILE: server/src/auth/twitch.js ================================================ 'use strict'; const logger = require('../logger'); const auth_utils = require('./utils'); const https = require('https'); const Joi = require('joi'); const querystring = require('querystring'); const url = require('url'); const options_schema = Joi.object().keys({ path: Joi.string().required(), id: Joi.string().required(), secret: Joi.string().required(), }).unknown(false); function twitch(horizon, raw_options) { const options = Joi.attempt(raw_options, options_schema); const client_id = options.id; const client_secret = options.secret; const provider = options.path; const oauth_options = { horizon, provider }; oauth_options.make_acquire_url = (state, redirect_uri) => url.format({ protocol: 'https', host: 'api.twitch.tv', pathname: '/kraken/oauth2/authorize', query: { client_id, redirect_uri, state, response_type: 'code', scope: 'user_read' } }); oauth_options.make_token_request = (code, redirect_uri) => { const req = https.request({ method: 'POST', host: 'api.twitch.tv', path: '/kraken/oauth2/token' }); req.write(querystring.stringify({ client_id, redirect_uri, client_secret, code, grant_type: 'authorization_code' })); return req; }; oauth_options.make_inspect_request = (access_token) => { logger.debug(`using access token: ${access_token}`); return https.request({ host: 'api.twitch.tv', path: '/kraken/user', headers: { authorization: `OAuth ${access_token}` } }); }; oauth_options.extract_id = (user_info) => user_info && user_info._id; auth_utils.oauth2(oauth_options); } module.exports = twitch; ================================================ FILE: server/src/auth/twitter.js ================================================ 'use strict'; const logger = require('../logger'); const auth_utils = require('./utils'); const Joi = require('joi'); const oauth = require('oauth'); const url = require('url'); const options_schema = Joi.object({ path: Joi.string().required(), id: Joi.string().required(), secret: Joi.string().required(), }); // Cache for request token secrets const nonce_cache = new Map(); const nonce_cache_ttl_ms = 60 * 60 * 1000; const store_app_token = (nonce, token) => { const time = Date.now(); const cutoff = time - nonce_cache_ttl_ms; const iter = nonce_cache.entries(); let item = iter.next(); while (item.value && item.value[1].time < cutoff) { nonce_cache.delete(item.value[0]); item = iter.next(); } nonce_cache.set(nonce, { time, token }); }; const get_app_token = (nonce) => { const res = nonce_cache.get(nonce); nonce_cache.delete(nonce); return res && res.token; }; function twitter(horizon, raw_options) { const options = Joi.attempt(raw_options, options_schema); const provider = options.path; const consumer_key = options.id; const consumer_secret = options.secret; const oa = new oauth.OAuth('https://twitter.com/oauth/request_token', 'https://twitter.com/oauth/access_token', consumer_key, consumer_secret, '1.0a', '', // Callback URL, to be filled in per-user 'HMAC-SHA1'); const user_info_url = 'https://api.twitter.com/1.1/account/verify_credentials.json'; const make_success_url = (horizon_token) => url.format(auth_utils.extend_url_query(horizon._auth._success_redirect, { horizon_token })); const make_failure_url = (horizon_error) => url.format(auth_utils.extend_url_query(horizon._auth._failure_redirect, { horizon_error })); horizon.add_http_handler(provider, (req, res) => { const request_url = url.parse(req.url, true); const user_token = request_url.query && request_url.query.oauth_token; const verifier = request_url.query && request_url.query.oauth_verifier; logger.debug(`oauth request: ${JSON.stringify(request_url)}`); if (!user_token) { // Auth has not been started yet, determine our callback URL and register an app token for it // First generate a nonce to track this client session to prevent CSRF attacks auth_utils.make_nonce((nonce_err, nonce) => { if (nonce_err) { logger.error(`Error creating nonce for oauth state: ${nonce_err}`); auth_utils.do_redirect(res, make_failure_url('error generating nonce')); } else { oa._authorize_callback = url.format({ protocol: 'https', host: req.headers.host, pathname: request_url.pathname, query: { state: auth_utils.nonce_to_state(nonce) } }); oa.getOAuthRequestToken((err, app_token, app_token_secret, body) => { if (err || body.oauth_callback_confirmed !== 'true') { logger.error(`Error acquiring app oauth token: ${JSON.stringify(err)}`); auth_utils.do_redirect(res, make_failure_url('error acquiring app oauth token')); } else { store_app_token(nonce, app_token_secret); auth_utils.set_nonce(res, horizon._name, nonce); auth_utils.do_redirect(res, url.format({ protocol: 'https', host: 'api.twitter.com', pathname: '/oauth/authenticate', query: { oauth_token: app_token } })); } }); } }); } else { // Make sure this is the same client who obtained the code to prevent CSRF attacks const nonce = auth_utils.get_nonce(req, horizon._name); const state = request_url.query.state; const app_token = get_app_token(nonce); if (!nonce || !state || !app_token || state !== auth_utils.nonce_to_state(nonce)) { auth_utils.do_redirect(res, make_failure_url('session expired')); } else { oa.getOAuthAccessToken(user_token, app_token, verifier, (err, access_token, secret) => { if (err) { logger.error(`Error contacting oauth API: ${err}`); auth_utils.do_redirect(res, make_failure_url('oauth provider error')); } else { oa.get(user_info_url, access_token, secret, (err2, body) => { const user_info = auth_utils.try_json_parse(body); const user_id = user_info && user_info.id; if (err2) { logger.error(`Error contacting oauth API: ${err2}`); auth_utils.do_redirect(res, make_failure_url('oauth provider error')); } else if (!user_id) { logger.error(`Bad JSON data from oauth API: ${body}`); auth_utils.do_redirect(res, make_failure_url('unparseable inspect response')); } else { horizon._auth.generate(provider, user_id).nodeify((err3, jwt) => { auth_utils.clear_nonce(res, horizon._name); auth_utils.do_redirect(res, err3 ? make_failure_url('invalid user') : make_success_url(jwt.token)); }); } }); } }); } } }); } module.exports = twitter; ================================================ FILE: server/src/auth/utils.js ================================================ 'use strict'; const logger = require('../logger'); const cookie = require('cookie'); const crypto = require('crypto'); const Joi = require('joi'); const url = require('url'); const do_redirect = (res, redirect_url) => { logger.debug(`Redirecting user to ${redirect_url}`); res.writeHead(302, { Location: redirect_url }); res.end(); }; const extend_url_query = (path, query) => { const path_copy = Object.assign({}, path); if (path_copy.query === null) { path_copy.query = query; } else { path_copy.query = Object.assign({}, path_copy.query); path_copy.query = Object.assign({}, path_copy.query, query); } return path_copy; }; const run_request = (req, cb) => { logger.debug(`Initiating request to ${req._headers.host}${req.path}`); req.once('response', (res) => { const chunks = []; res.on('data', (data) => { chunks.push(data); }); res.once('end', () => { if (res.statusCode !== 200) { cb(new Error(`Request returned status code: ${res.statusCode} ` + `(${res.statusMessage}): ${chunks.join('')}`)); } else { cb(null, chunks.join('')); } }); }); req.once('error', (err) => { cb(err); }); req.end(); }; const try_json_parse = (data) => { try { return JSON.parse(data); } catch (err) { // Do nothing - just return undefined } }; const nonce_cookie = (name) => `${name}_horizon_nonce`; const make_nonce = (cb) => crypto.randomBytes(64, (err, res) => { if (!err) { cb(err, res.toString('base64')); } else { cb(err, res); } }); // TODO: this base64 encoding isn't URL-friendly const nonce_to_state = (nonce) => crypto.createHash('sha256').update(nonce, 'base64').digest('base64'); const set_nonce = (res, name, nonce) => res.setHeader('set-cookie', cookie.serialize(nonce_cookie(name), nonce, { maxAge: 3600, secure: true, httpOnly: true })); const clear_nonce = (res, name) => res.setHeader('set-cookie', cookie.serialize(nonce_cookie(name), 'invalid', { maxAge: -1, secure: true, httpOnly: true })); const get_nonce = (req, name) => { const field = nonce_cookie(name); if (req.headers.cookie) { const value = cookie.parse(req.headers.cookie); return value[field]; } }; const options_schema = Joi.object({ horizon: Joi.object().required(), provider: Joi.string().required(), make_acquire_url: Joi.func().arity(2).required(), // take `state` and `return_url`, return string make_token_request: Joi.func().arity(2).required(), // take `code` and `return_url`, return request make_inspect_request: Joi.func().arity(1).required(), // take `access_token`, return request extract_id: Joi.func().arity(1).required(), // take `user_info`, return value }).unknown(false); // Attaches an endpoint to the horizon server, providing an oauth2 redirect flow const oauth2 = (raw_options) => { const options = Joi.attempt(raw_options, options_schema); const horizon = options.horizon; const provider = options.provider; const make_acquire_url = options.make_acquire_url; const make_token_request = options.make_token_request; const make_inspect_request = options.make_inspect_request; const extract_id = options.extract_id; const self_url = (host, path) => url.format({ protocol: 'https', host: host, pathname: path }); const make_success_url = (horizon_token) => url.format(extend_url_query(horizon._auth._success_redirect, { horizon_token })); const make_failure_url = (horizon_error) => url.format(extend_url_query(horizon._auth._failure_redirect, { horizon_error })); horizon.add_http_handler(provider, (req, res) => { const request_url = url.parse(req.url, true); const return_url = self_url(req.headers.host, request_url.pathname); const code = request_url.query && request_url.query.code; const error = request_url.query && request_url.query.error; logger.debug(`oauth request: ${JSON.stringify(request_url)}`); if (error) { const description = request_url.query.error_description || error; do_redirect(res, make_failure_url(description)); } else if (!code) { // We need to redirect to the API to acquire a token, then come back and try again // Generate a nonce to track this client session to prevent CSRF attacks make_nonce((nonce_err, nonce) => { if (nonce_err) { logger.error(`Error creating nonce for oauth state: ${nonce_err}`); res.statusCode = 503; res.end('error generating nonce'); } else { set_nonce(res, horizon._name, nonce); do_redirect(res, make_acquire_url(nonce_to_state(nonce), return_url)); } }); } else { // Make sure this is the same client who obtained the code to prevent CSRF attacks const nonce = get_nonce(req, horizon._name); const state = request_url.query.state; if (!nonce || !state || state !== nonce_to_state(nonce)) { do_redirect(res, make_failure_url('session expired')); } else { // We have the user code, turn it into an access token run_request(make_token_request(code, return_url), (err1, body) => { const info = try_json_parse(body); const access_token = info && info.access_token; if (err1) { logger.error(`Error contacting oauth API: ${err1}`); res.statusCode = 503; res.end('oauth provider error'); } else if (!access_token) { logger.error(`Bad JSON data from oauth API: ${body}`); res.statusCode = 500; res.end('unparseable token response'); } else { // We have the user access token, get info on it so we can find the user run_request(make_inspect_request(access_token), (err2, inner_body) => { const user_info = try_json_parse(inner_body); const user_id = user_info && extract_id(user_info); if (err2) { logger.error(`Error contacting oauth API: ${err2}`); res.statusCode = 503; res.end('oauth provider error'); } else if (!user_id) { logger.error(`Bad JSON data from oauth API: ${inner_body}`); res.statusCode = 500; res.end('unparseable inspect response'); } else { horizon._auth.generate(provider, user_id).nodeify((err3, jwt) => { // Clear the nonce just so we aren't polluting clients' cookies clear_nonce(res, horizon._name); do_redirect(res, err3 ? make_failure_url('invalid user') : make_success_url(jwt.token)); }); } }); } }); } } }); }; module.exports = { oauth2, do_redirect, run_request, make_nonce, set_nonce, get_nonce, clear_nonce, nonce_to_state, extend_url_query, try_json_parse, }; ================================================ FILE: server/src/auth.js ================================================ 'use strict'; const logger = require('./logger'); const options_schema = require('./schema/server_options').auth; const writes = require('./endpoint/writes'); const Joi = require('joi'); const Promise = require('bluebird'); const jwt = Promise.promisifyAll(require('jsonwebtoken')); const r = require('rethinkdb'); const url = require('url'); class JWT { constructor(options) { this.duration = options.duration; this.algorithm = 'HS512'; if (options.token_secret != null) { this.secret = new Buffer(options.token_secret, 'base64'); } else { throw new Error( 'No token_secret set! Try setting it in .hz/secrets.toml ' + 'or passing it to the Server constructor.'); } } // A generated token contains the data: // { id: , provider: } sign(payload) { const token = jwt.sign( payload, this.secret, { algorithm: this.algorithm, expiresIn: this.duration } ); return { token, payload }; } verify(token) { return jwt.verifyAsync(token, this.secret, { algorithms: [ this.algorithm ] }) .then((payload) => ({ token, payload })); } } class Auth { constructor(server, user_options) { const options = Joi.attempt(user_options, options_schema); this._jwt = new JWT(options); this._success_redirect = url.parse(options.success_redirect); this._failure_redirect = url.parse(options.failure_redirect); this._create_new_users = options.create_new_users; this._new_user_group = options.new_user_group; this._allow_anonymous = options.allow_anonymous; this._allow_unauthenticated = options.allow_unauthenticated; this._parent = server; } handshake(request) { switch (request.method) { case 'token': return this._jwt.verify(request.token); case 'unauthenticated': if (!this._allow_unauthenticated) { throw new Error('Unauthenticated connections are not allowed.'); } return this._jwt.verify(this._jwt.sign({ id: null, provider: request.method }).token); case 'anonymous': if (!this._allow_anonymous) { throw new Error('Anonymous connections are not allowed.'); } return this.generate(request.method, r.uuid()); default: throw new Error(`Unknown handshake method "${request.method}"`); } } // Can't use objects in primary keys, so convert those to JSON in the db (deterministically) auth_key(provider, info) { if (info === null || Array.isArray(info) || typeof info !== 'object') { return [ provider, info ]; } else { return [ provider, r.expr(info).toJSON() ]; } } new_user_row(id) { return { id, groups: [ 'default', this._new_user_group ], [writes.version_field]: 0, }; } // TODO: maybe we should write something into the user data to track open sessions/tokens generate(provider, info) { return Promise.resolve().then(() => { const key = this.auth_key(provider, info); const db = r.db(this._parent._name); const insert = (table, row) => db.table(table) .insert(row, { conflict: 'error', returnChanges: 'always' }) .bracket('changes')(0)('new_val'); let query = db.table('users') .get(db.table('hz_users_auth').get(key)('user_id')) .default(r.error('User not found and new user creation is disabled.')); if (this._create_new_users) { query = insert('hz_users_auth', { id: key, user_id: r.uuid() }) .do((auth_user) => insert('users', this.new_user_row(auth_user('user_id')))); } return query.run(this._parent._reql_conn.connection()).catch((err) => { // TODO: if we got a `Duplicate primary key` error, it was likely a race condition // and we should succeed if we try again. logger.debug(`Failed user lookup or creation: ${err}`); throw new Error('User lookup or creation in database failed.'); }); }).then((user) => this._jwt.sign({ id: user.id, provider }) ); } } module.exports = { Auth }; ================================================ FILE: server/src/client.js ================================================ 'use strict'; const logger = require('./logger'); const schemas = require('./schema/horizon_protocol'); const Request = require('./request').Request; const Joi = require('joi'); const websocket = require('ws'); class Client { constructor(socket, server, metadata) { logger.debug('Client connection established.'); this._socket = socket; this._server = server; this._auth = this._server._auth; this._permissions_enabled = this._server._permissions_enabled; this._metadata = metadata; this._requests = new Map(); this.user_info = { }; this._socket.on('close', (code, msg) => this.handle_websocket_close(code, msg)); this._socket.on('error', (error) => this.handle_websocket_error(error)); // The first message should always be the handshake this._socket.once('message', (data) => this.error_wrap_socket(() => this.handle_handshake(data))); if (server._max_connections !== null && server._reql_conn._clients.size >= server._max_connections) { this.close({ request_id: null, error: 'Max connections limit reached.', error_code: 0 }); } } handle_websocket_close() { logger.debug('Client connection terminated.'); if (this.user_feed) { this.user_feed.close().catch(() => { }); } this._requests.forEach((request) => { request.close(); }); this._requests.clear(); this._server._reql_conn._clients.delete(this); } handle_websocket_error(code, msg) { logger.error(`Received error from client: ${msg} (${code})`); } error_wrap_socket(cb) { try { cb(); } catch (err) { logger.debug(`Unhandled error in request: ${err.stack}`); this.close({ request_id: null, error: `Unhandled error: ${err}`, error_code: 0 }); } } parse_request(data, schema) { let request; try { request = JSON.parse(data); } catch (err) { return this.close({ request_id: null, error: `Invalid JSON: ${err}`, error_code: 0 }); } try { return Joi.attempt(request, schema); } catch (err) { const detail = err.details[0]; const err_str = `Request validation error at "${detail.path}": ${detail.message}`; const request_id = request.request_id === undefined ? null : request.request_id; if (request.request_id === undefined) { // This is pretty much an unrecoverable protocol error, so close the connection this.close({ request_id, error: `Protocol error: ${err}`, error_code: 0 }); } else { this.send_error({ request_id }, err_str); } } } group_changed(group_name) { if (this.user_info.groups.indexOf(group_name) !== -1) { this._requests.forEach((req) => req.evaluate_rules()); } } handle_handshake(data) { const request = this.parse_request(data, schemas.handshake); logger.debug(`Received handshake: ${JSON.stringify(request)}`); if (request === undefined) { return this.close({ error: 'Invalid handshake.', error_code: 0 }); } let responded = false; this._auth.handshake(request).then((res) => { const finish_handshake = () => { if (!responded) { responded = true; const info = { token: res.token, id: res.payload.id, provider: res.payload.provider }; this.send_response(request, info); this._socket.on('message', (msg) => this.error_wrap_socket(() => this.handle_request(msg))); } }; this.user_info = res.payload; if (this.user_info.id != null) { return this._metadata.get_user_feed(this.user_info.id).then((feed) => { this.user_feed = feed; return feed.eachAsync((change) => { if (!change.new_val) { throw new Error('User account has been deleted.'); } Object.assign(this.user_info, change.new_val); this._requests.forEach((req) => req.evaluate_rules()); finish_handshake(); }).then(() => { throw new Error('User account feed has been lost.'); }); }); } else { this.user_info.groups = [ 'default' ]; finish_handshake(); } }).catch((err) => { if (!responded) { responded = true; this.close({ request_id: request.request_id, error: `${err}`, error_code: 0 }); } }); } handle_request(data) { logger.debug(`Received request from client: ${data}`); const raw_request = this.parse_request(data, schemas.request); if (raw_request === undefined) { return; } else if (raw_request.type === 'end_subscription') { return this.remove_request(raw_request); // there is no response for end_subscription } else if (raw_request.type === 'keepalive') { return this.send_response(raw_request, { state: 'complete' }); } const endpoint = this._server.get_request_handler(raw_request); if (endpoint === undefined) { return this.send_error(raw_request, `"${raw_request.type}" is not a registered request type.`); } else if (this._requests.has(raw_request.request_id)) { return this.send_error(raw_request, `Request ${raw_request.request_id} already exists for this client.`); } const request = new Request(raw_request, endpoint, this); this._requests.set(raw_request.request_id, request); request.run(); } remove_request(raw_request) { const request = this._requests.get(raw_request.request_id); this._requests.delete(raw_request.request_id); if (request) { request.close(); } } is_open() { return this._socket.readyState === websocket.OPEN; } close(info) { if (this.is_open()) { const close_msg = (info.error && info.error.substr(0, 64)) || 'Unspecified reason.'; logger.debug('Closing client connection with message: ' + `${info.error || 'Unspecified reason.'}`); logger.debug(`info: ${JSON.stringify(info)}`); if (info.request_id !== undefined) { this._socket.send(JSON.stringify(info)); } this._socket.close(1002, close_msg); } } send_response(request, data) { // Ignore responses for disconnected clients if (this.is_open()) { data.request_id = request.request_id; logger.debug(`Sending response: ${JSON.stringify(data)}`); this._socket.send(JSON.stringify(data)); } } send_error(request, err, code) { logger.debug(`Sending error result for request ${request.request_id}:\n${err.stack}`); const error = err instanceof Error ? err.message : err; const error_code = code === undefined ? -1 : code; this.send_response(request, { error, error_code }); } } const make_client = (socket, server) => { try { const metadata = server._reql_conn.metadata(); const client = new Client(socket, server, metadata); server._reql_conn._clients.add(client); } catch (err) { logger.debug(`Rejecting client connection because of error: ${err.message}`); socket.close(1002, err.message.substr(0, 64)); } }; module.exports = { make_client }; ================================================ FILE: server/src/endpoint/common.js ================================================ 'use strict'; const reql_options = { timeFormat: 'raw', binaryFormat: 'raw', }; module.exports = { reql_options, }; ================================================ FILE: server/src/endpoint/insert.js ================================================ 'use strict'; const insert = require('../schema/horizon_protocol').insert; const writes = require('./writes'); const reql_options = require('./common').reql_options; const Joi = require('joi'); const r = require('rethinkdb'); const run = (raw_request, context, ruleset, metadata, send, done) => { const parsed = Joi.validate(raw_request.options, insert); if (parsed.error !== null) { done(new Error(parsed.error.details[0].message)); } const collection = metadata.collection(parsed.value.collection); const conn = metadata.connection(); writes.retry_loop(parsed.value.data, ruleset, parsed.value.timeout, (rows) => // pre-validation, all rows Array(rows.length).fill(null), (row, info) => { // validation, each row if (!ruleset.validate(context, info, row)) { return new Error(writes.unauthorized_msg); } }, (rows) => // write to database, all valid rows collection.table .insert(rows.map((row) => writes.apply_version(r.expr(row), 0)), { returnChanges: 'always' }) .run(conn, reql_options) ).then(done).catch(done); }; module.exports = { run }; ================================================ FILE: server/src/endpoint/query.js ================================================ 'use strict'; const query = require('../schema/horizon_protocol').query; const check = require('../error.js').check; const reql_options = require('./common').reql_options; const Joi = require('joi'); const r = require('rethinkdb'); const object_to_fields = (obj) => Object.keys(obj).map((key) => { const value = obj[key]; if (value !== null && typeof value === 'object' && !value['$reql_type$']) { return object_to_fields(value).map((subkeys) => [ key ].concat(subkeys)); } else { return [ key ]; } }); // This is exposed to be reused by 'subscribe' const make_reql = (raw_request, metadata) => { const parsed = Joi.validate(raw_request.options, query); if (parsed.error !== null) { throw new Error(parsed.error.details[0].message); } const options = parsed.value; const collection = metadata.collection(parsed.value.collection); let reql = collection.table; const ordered_between = (obj) => { const fuzzy_fields = object_to_fields(obj); const order_keys = (options.order && options.order[0]) || (options.above && Object.keys(options.above[0])) || (options.below && Object.keys(options.below[0])) || [ ]; if (order_keys.length >= 1) { const k = order_keys[0]; check(!options.above || options.above[0][k] !== undefined, '"above" must be on the same field as the first in "order".'); check(!options.below || options.below[0][k] !== undefined, '"below" must be on the same field as the first in "order".'); } order_keys.forEach((k) => { check(obj[k] === undefined, `"${k}" cannot be used in "order", "above", or "below" when finding by that field.`); }); const index = collection.get_matching_index(fuzzy_fields, order_keys.map((k) => [ k ])); const get_bound = (name) => { const eval_key = (key) => { if (obj[key] !== undefined) { return obj[key]; } else if (options[name] && options[name][0][key] !== undefined) { return options[name][0][key]; } else if (options[name] && options[name][1] === 'open') { return name === 'above' ? r.maxval : r.minval; } else { return name === 'above' ? r.minval : r.maxval; } }; if (index.name === 'id') { return eval_key('id'); } return index.fields.map((k) => eval_key(k)); }; const above_value = get_bound('above'); const below_value = get_bound('below'); const optargs = { index: index.name, leftBound: options.above ? options.above[1] : 'closed', rightBound: options.below ? options.below[1] : 'closed', }; const order = (options.order && options.order[1] === 'descending') ? r.desc(index.name) : index.name; return reql.orderBy({ index: order }).between(above_value, below_value, optargs); }; if (options.find) { reql = ordered_between(options.find).limit(1); } else if (options.find_all && options.find_all.length > 1) { reql = r.union.apply(r, options.find_all.map((x) => ordered_between(x))); } else { reql = ordered_between((options.find_all && options.find_all[0]) || { }); } if (options.limit !== undefined) { reql = reql.limit(options.limit); } return reql; }; const run = (raw_request, context, ruleset, metadata, send, done) => { let cursor; const reql = make_reql(raw_request, metadata); reql.run(metadata.connection(), reql_options).then((res) => { if (res !== null && res.constructor.name === 'Cursor') { cursor = res; return cursor.eachAsync((item) => { if (!ruleset.validate(context, item)) { done(new Error('Operation not permitted.')); cursor.close().catch(() => { }); } else { send({ data: [ item ] }); } }).then(() => { done({ data: [ ], state: 'complete' }); }); } else if (res !== null && res.constructor.name === 'Array') { for (const item of res) { if (!ruleset.validate(context, item)) { return done(new Error('Operation not permitted.')); } } done({ data: res, state: 'complete' }); } else if (!ruleset.validate(context, res)) { done(new Error('Operation not permitted.')); } else { done({ data: [ res ], state: 'complete' }); } }).catch(done); return () => { if (cursor) { cursor.close().catch(() => { }); } }; }; module.exports = { make_reql, run }; ================================================ FILE: server/src/endpoint/remove.js ================================================ 'use strict'; const remove = require('../schema/horizon_protocol').remove; const reql_options = require('./common').reql_options; const writes = require('./writes'); const hz_v = writes.version_field; const Joi = require('joi'); const r = require('rethinkdb'); const run = (raw_request, context, ruleset, metadata, send, done) => { const parsed = Joi.validate(raw_request.options, remove); if (parsed.error !== null) { throw new Error(parsed.error.details[0].message); } const collection = metadata.collection(parsed.value.collection); const conn = metadata.connection(); writes.retry_loop(parsed.value.data, ruleset, parsed.value.timeout, (rows) => // pre-validation, all rows r.expr(rows.map((row) => row.id)) .map((id) => collection.table.get(id)) .run(conn, reql_options), (row, info) => writes.validate_old_row_required(context, row, info, null, ruleset), (rows) => // write to database, all valid rows r.expr(rows).do((row_data) => row_data.forEach((info) => collection.table.get(info('id')).replace((row) => r.branch(// The row may have been deleted between the get and now row.eq(null), null, // The row may have been changed between the get and now r.and(info.hasFields(hz_v), row(hz_v).default(-1).ne(info(hz_v))), r.error(writes.invalidated_msg), // Otherwise, we can safely remove the row null), { returnChanges: 'always' })) // Pretend like we deleted rows that didn't exist .do((res) => res.merge({ changes: r.range(row_data.count()).map((index) => r.branch(res('changes')(index)('old_val').eq(null), res('changes')(index).merge({ old_val: { id: row_data(index)('id') } }), res('changes')(index))).coerceTo('array'), }))) .run(conn, reql_options) ).then(done).catch(done); }; module.exports = { run }; ================================================ FILE: server/src/endpoint/replace.js ================================================ 'use strict'; const replace = require('../schema/horizon_protocol').replace; const reql_options = require('./common').reql_options; const writes = require('./writes'); const hz_v = writes.version_field; const Joi = require('joi'); const r = require('rethinkdb'); const run = (raw_request, context, ruleset, metadata, send, done) => { const parsed = Joi.validate(raw_request.options, replace); if (parsed.error !== null) { throw new Error(parsed.error.details[0].message); } const collection = metadata.collection(parsed.value.collection); const conn = metadata.connection(); writes.retry_loop(parsed.value.data, ruleset, parsed.value.timeout, (rows) => // pre-validation, all rows r.expr(rows.map((row) => row.id)) .map((id) => collection.table.get(id)) .run(conn, reql_options), (row, info) => writes.validate_old_row_required(context, row, info, row, ruleset), (rows) => // write to database, all valid rows r.expr(rows) .forEach((new_row) => collection.table.get(new_row('id')).replace((old_row) => r.branch(// The row may have been deleted between the get and now old_row.eq(null), r.error(writes.missing_msg), // The row may have been changed between the get and now r.and(new_row.hasFields(hz_v), old_row(hz_v).default(-1).ne(new_row(hz_v))), r.error(writes.invalidated_msg), // Otherwise, we can safely replace the row writes.apply_version(new_row, old_row(hz_v).default(-1).add(1))), { returnChanges: 'always' })) .run(conn, reql_options) ).then(done).catch(done); }; module.exports = { run }; ================================================ FILE: server/src/endpoint/store.js ================================================ 'use strict'; const store = require('../schema/horizon_protocol').store; const reql_options = require('./common').reql_options; const writes = require('./writes'); const hz_v = writes.version_field; const Joi = require('joi'); const r = require('rethinkdb'); const run = (raw_request, context, ruleset, metadata, send, done) => { const parsed = Joi.validate(raw_request.options, store); if (parsed.error !== null) { throw new Error(parsed.error.details[0].message); } const collection = metadata.collection(parsed.value.collection); const conn = metadata.connection(); writes.retry_loop(parsed.value.data, ruleset, parsed.value.timeout, (rows) => // pre-validation, all rows r.expr(rows.map((row) => (row.id === undefined ? null : row.id))) .map((id) => r.branch(id.eq(null), null, collection.table.get(id))) .run(conn, reql_options), (row, info) => writes.validate_old_row_optional(context, row, info, row, ruleset), (rows) => // write to database, all valid rows r.expr(rows) .forEach((new_row) => r.branch(new_row.hasFields('id'), collection.table.get(new_row('id')).replace((old_row) => r.branch( old_row.eq(null), r.branch( // Error if we were expecting the row to exist new_row.hasFields(hz_v), r.error(writes.invalidated_msg), // Otherwise, insert the row writes.apply_version(new_row, 0) ), r.branch( // The row may have changed from the expected version r.and(new_row.hasFields(hz_v), old_row(hz_v).default(-1).ne(new_row(hz_v))), r.error(writes.invalidated_msg), // Otherwise, we can safely overwrite the row writes.apply_version(new_row, old_row(hz_v).default(-1).add(1)) ) ), { returnChanges: 'always' }), // The new row does not have an id, so we insert it with an autogen id collection.table.insert(writes.apply_version(new_row, 0), { returnChanges: 'always' }))) .run(conn, reql_options) ).then(done).catch(done); }; module.exports = { run }; ================================================ FILE: server/src/endpoint/subscribe.js ================================================ 'use strict'; const make_reql = require('./query').make_reql; const reql_options = require('./common').reql_options; const run = (raw_request, context, ruleset, metadata, send, done) => { let feed; const reql = make_reql(raw_request, metadata); reql.changes({ include_initial: true, include_states: true, include_types: true, include_offsets: Boolean(raw_request.options.order) && Boolean(raw_request.options.limit) }) .run(metadata.connection(), reql_options) .then((res) => { feed = res; feed.eachAsync((item) => { if (item.state === 'initializing') { // Do nothing - we don't care } else if (item.state === 'ready') { send({ state: 'synced' }); } else if ((item.old_val && !ruleset.validate(context, item.old_val)) || (item.new_val && !ruleset.validate(context, item.new_val))) { throw new Error('Operation not permitted.'); } else { send({ data: [ item ] }); } }).then(() => { done({ state: 'complete' }); }).catch(done); }).catch(done); return () => { if (feed) { feed.close().catch(() => { }); } }; }; module.exports = { run }; ================================================ FILE: server/src/endpoint/update.js ================================================ 'use strict'; const update = require('../schema/horizon_protocol').update; const reql_options = require('./common').reql_options; const writes = require('./writes'); const hz_v = writes.version_field; const Joi = require('joi'); const r = require('rethinkdb'); const run = (raw_request, context, ruleset, metadata, send, done) => { const parsed = Joi.validate(raw_request.options, update); if (parsed.error !== null) { throw new Error(parsed.error.details[0].message); } const collection = metadata.collection(parsed.value.collection); const conn = metadata.connection(); writes.retry_loop(parsed.value.data, ruleset, parsed.value.timeout, (rows) => // pre-validation, all rows r.expr(rows) .map((new_row) => collection.table.get(new_row('id')).do((old_row) => r.branch(old_row.eq(null), null, [ old_row, old_row.merge(new_row) ]))) .run(conn, reql_options), (row, info) => writes.validate_old_row_required(context, row, info[0], info[1], ruleset), (rows) => // write to database, all valid rows r.expr(rows) .forEach((new_row) => collection.table.get(new_row('id')).replace((old_row) => r.branch(// The row may have been deleted between the get and now old_row.eq(null), r.error(writes.missing_msg), // The row may have been changed between the get and now r.and(new_row.hasFields(hz_v), old_row(hz_v).default(-1).ne(new_row(hz_v))), r.error(writes.invalidated_msg), // Otherwise we can safely update the row and increment the version writes.apply_version(old_row.merge(new_row), old_row(hz_v).default(-1).add(1))), { returnChanges: 'always' })) .run(conn, reql_options) ).then(done).catch(done); }; module.exports = { run }; ================================================ FILE: server/src/endpoint/upsert.js ================================================ 'use strict'; const upsert = require('../schema/horizon_protocol').upsert; const reql_options = require('./common').reql_options; const writes = require('./writes'); const hz_v = writes.version_field; const Joi = require('joi'); const r = require('rethinkdb'); const run = (raw_request, context, ruleset, metadata, send, done) => { const parsed = Joi.validate(raw_request.options, upsert); if (parsed.error !== null) { throw new Error(parsed.error.details[0].message); } const collection = metadata.collection(parsed.value.collection); const conn = metadata.connection(); writes.retry_loop(parsed.value.data, ruleset, parsed.value.timeout, (rows) => // pre-validation, all rows r.expr(rows) .map((new_row) => r.branch(new_row.hasFields('id'), collection.table.get(new_row('id')).do((old_row) => r.branch(old_row.eq(null), [ null, new_row ], [ old_row, old_row.merge(new_row) ])), [ null, new_row ])) .run(conn, reql_options), (row, info) => writes.validate_old_row_optional(context, row, info[0], info[1], ruleset), (rows) => // write to database, all valid rows r.expr(rows) .forEach((new_row) => r.branch(new_row.hasFields('id'), collection.table.get(new_row('id')).replace((old_row) => r.branch( old_row.eq(null), r.branch( // Error if we were expecting the row to exist new_row.hasFields(hz_v), r.error(writes.invalidated_msg), // Otherwise, insert the row writes.apply_version(new_row, 0) ), r.branch( // The row may have changed from the expected version r.and(new_row.hasFields(hz_v), old_row(hz_v).default(-1).ne(new_row(hz_v))), r.error(writes.invalidated_msg), // Otherwise, we can safely update the row and increment the version writes.apply_version(old_row.merge(new_row), old_row(hz_v).default(-1).add(1)) ) ), { returnChanges: 'always' }), // The new row did not have an id, so we insert it with an autogen id collection.table.insert(writes.apply_version(new_row, 0), { returnChanges: 'always' }))) .run(conn, reql_options) ).then(done).catch(done); }; module.exports = { run }; ================================================ FILE: server/src/endpoint/writes.js ================================================ 'use strict'; const check = require('../error').check; const r = require('rethinkdb'); // Common functionality used by write requests const invalidated_msg = 'Write invalidated by another request, try again.'; const missing_msg = 'The document was missing.'; const timeout_msg = 'Operation timed out.'; const unauthorized_msg = 'Operation not permitted.'; const hz_v = '$hz_v$'; const apply_version = (row, new_version) => row.merge(r.object(hz_v, new_version)); const make_write_response = (data) => { data.forEach((item, index) => { if (item instanceof Error) { data[index] = { error: item.message }; } }); return { data, state: 'complete' }; }; // This function returns a Promise that resolves to an array of responses - one for each row in // `original_rows`, or rejects with an appropriate error. // timeout -> integer // minimum number of milliseconds before giving up on retrying writes // null means no timeout // pre_validate -> function (rows): // rows: all pending rows // return: an array or the promise of an array of info for those rows // (which will be passed to the validate callback) // validate_row -> function (row, info): // row: The row from the original query // info: The info returned by the pre_validate step for this row // return: nothing if successful or an error to be put as the response for this row // do_write -> function (rows): // rows: all pending rows // return: a (promise of a) ReQL write result object const retry_loop = (original_rows, ruleset, timeout, pre_validate, validate_row, do_write) => { const iterate = (row_data, response_data, deadline_optional) => { let deadline = deadline_optional; if (row_data.length === 0) { return response_data; } else if (timeout !== null) { if (!deadline) { deadline = Date.now() + timeout; } else if (Date.now() > deadline) { response_data.forEach((data, index) => { if (data === null) { response_data[index] = new Error(timeout_msg); } }); return response_data; } } return Promise.resolve().then(() => { // The validate callback may clobber the original version field in the row, // so we have to restore it to the original value. // This is done because validation only approves moving from one specific // version of the row to another. Even if the original request did not choose // the version, we are locked in to the version fetched from the pre_validate // callback until the next iteration. If the version has changed in the meantime, // it is an invalidated error which may be retried until we hit the deadline. row_data.forEach((data) => { if (data.version === undefined) { delete data.row[hz_v]; } else { data.row[hz_v] = data.version; } }); if (ruleset.validation_required()) { // For the set of rows to write, gather info for the validation step return Promise.resolve(pre_validate(row_data.map((data) => data.row))).then((infos) => { check(infos.length === row_data.length); // For each row to write (and info), validate it with permissions const valid_rows = [ ]; row_data.forEach((data, i) => { const res = validate_row(data.row, infos[i]); if (res !== undefined) { response_data[data.index] = res; } else { valid_rows.push(data); } }); row_data = valid_rows; }); } }).then(() => { // For the set of valid rows, call the write step if (row_data.length === 0) { return [ ]; } return do_write(row_data.map((data) => data.row)).then((res) => res.changes); }).then((changes) => { check(changes.length === row_data.length); // Remove successful writes and invalidated writes that had an initial version const retry_rows = [ ]; row_data.forEach((data, index) => { const res = changes[index]; if (res.error !== undefined) { if (res.error.indexOf('Duplicate primary key') === 0) { response_data[data.index] = { error: 'The document already exists.' }; } else if (res.error.indexOf(invalidated_msg) === 0 && data.version === undefined) { retry_rows.push(data); } else { response_data[data.index] = { error: res.error }; } } else if (res.new_val === null) { response_data[data.index] = { id: res.old_val.id, [hz_v]: res.old_val[hz_v] }; } else { response_data[data.index] = { id: res.new_val.id, [hz_v]: res.new_val[hz_v] }; } }); // Recurse, after which it will decide if there is more work to be done return iterate(retry_rows, response_data, deadline); }); }; return iterate(original_rows.map((row, index) => ({ row, index, version: row[hz_v] })), Array(original_rows.length).fill(null), null).then(make_write_response); }; const validate_old_row_optional = (context, original, old_row, new_row, ruleset) => { const expected_version = original[hz_v]; if (expected_version !== undefined && (!old_row || expected_version !== old_row[hz_v])) { return new Error(invalidated_msg); } else if (!ruleset.validate(context, old_row, new_row)) { return new Error(unauthorized_msg); } if (old_row) { const old_version = old_row[hz_v]; if (expected_version === undefined) { original[hz_v] = old_version === undefined ? -1 : old_version; } } }; const validate_old_row_required = (context, original, old_row, new_row, ruleset) => { if (old_row === null) { return new Error(missing_msg); } const old_version = old_row[hz_v]; const expected_version = original[hz_v]; if (expected_version !== undefined && expected_version !== old_version) { return new Error(invalidated_msg); } else if (!ruleset.validate(context, old_row, new_row)) { return new Error(unauthorized_msg); } if (expected_version === undefined) { original[hz_v] = old_version === undefined ? -1 : old_version; } }; module.exports = { invalidated_msg, missing_msg, timeout_msg, unauthorized_msg, make_write_response, version_field: hz_v, apply_version, retry_loop, validate_old_row_required, validate_old_row_optional, }; ================================================ FILE: server/src/error.js ================================================ 'use strict'; const check = (pred, message) => { if (!pred) { throw new Error(message); } }; const fail = (message) => check(false, message); class IndexMissing extends Error { constructor(collection, fields) { super(`Collection "${collection.name}" has no index matching ${JSON.stringify(fields)}.`); this.collection = collection; this.fields = fields; } } class CollectionMissing extends Error { constructor(name) { super(`Collection "${name}" does not exist.`); this.name = name; } } class IndexNotReady extends Error { constructor(collection, index) { super(`Index on collection "${collection.name}" is not ready: ${JSON.stringify(index.fields)}.`); this.collection = collection; this.index = index; } } class CollectionNotReady extends Error { constructor(collection) { super(`Collection "${collection.name}" is not ready.`); this.collection = collection; } } module.exports = { check, fail, IndexMissing, IndexNotReady, CollectionMissing, CollectionNotReady, }; ================================================ FILE: server/src/horizon.js ================================================ 'use strict'; const joi = require('joi'); // Issue a dummy joi validation to force joi to initialize its scripts. // This is used because tests will mock the filesystem, and the lazy // `require`s done by joi will no longer work at that point. joi.validate('', joi.any().when('', { is: '', then: joi.any() })); const server = require('./server'); const create_server = (http_servers, options) => new server.Server(http_servers, options); module.exports = create_server; module.exports.Server = server.Server; module.exports.r = require('rethinkdb'); module.exports.logger = require('./logger'); module.exports.utils = require('./utils'); module.exports.auth = { auth0: require('./auth/auth0'), facebook: require('./auth/facebook'), github: require('./auth/github'), google: require('./auth/google'), slack: require('./auth/slack'), twitch: require('./auth/twitch'), twitter: require('./auth/twitter'), }; ================================================ FILE: server/src/logger.js ================================================ 'use strict'; const winston = require('winston'); module.exports = winston; ================================================ FILE: server/src/metadata/collection.js ================================================ 'use strict'; const error = require('../error'); const Table = require('./table').Table; const r = require('rethinkdb'); class Collection { constructor(db, name) { this.name = name; this.table = r.db(db).table(name); // This is the ReQL Table object this._tables = new Map(); // A Map of Horizon Table objects this._registered = false; // Whether the `hz_collections` table says this collection exists this._waiters = [ ]; } _close() { this._tables.forEach((table) => { table._waiters.forEach((w) => w(new Error('collection deleted'))); table._waiters = [ ]; table.close(); }); this._waiters.forEach((w) => w(new Error('collection deleted'))); this._waiters = [ ]; } _update_table(table_id, indexes, conn) { let table = this._tables.get(table_id); if (indexes) { if (!table) { table = new Table(this.table, conn); this._tables.set(table_id, table); } table.update_indexes(indexes, conn); this._waiters.forEach((w) => table.on_ready(w)); this._waiters = [ ]; } else { this._tables.delete(table_id); if (table) { table._waiters.forEach((w) => this.on_ready(w)); table._waiters = [ ]; table.close(); } } } _register() { this._registered = true; } _unregister() { this._registered = false; } _is_safe_to_remove() { return this._tables.size === 0 && !this._registered; } _on_ready(done) { if (this._tables.size === 0) { this._waiters.push(done); } else { this._get_table().on_ready(done); } } _get_table() { if (this._tables.size === 0) { throw new error.CollectionNotReady(this); } return this._tables.values().next().value; } _create_index(fields, conn, done) { return this._get_table().create_index(fields, conn, done); } get_matching_index(fuzzy_fields, ordered_fields) { const match = this._get_table().get_matching_index(fuzzy_fields, ordered_fields); if (match && !match.ready()) { throw new error.IndexNotReady(this, match); } else if (!match) { throw new error.IndexMissing(this, fuzzy_fields.concat(ordered_fields)); } return match; } } module.exports = { Collection }; ================================================ FILE: server/src/metadata/index.js ================================================ 'use strict'; const check = require('../error').check; const logger = require('../logger'); // Index names are of the format "hz_[_]" where may be // omitted or "multi_" or "geo" (at the moment). is a JSON array // specifying which fields are indexed in which order. The value at each index // in the array is either a nested array (for indexing nested fields) or a string // for a root-level field name. // // Example: // Fields indexed: foo.bar, baz // Index name: hz_[["foo","bar"],"baz"] const primary_index_name = 'id'; const name_to_info = (name) => { if (name === primary_index_name) { return { geo: false, multi: false, fields: [ [ 'id' ] ] }; } const re = /^hz_(?:(geo)_)?(?:multi_([0-9])+_)?\[/; const matches = name.match(re); check(matches !== null, `Unexpected index name (invalid format): "${name}"`); const json_offset = matches[0].length - 1; const info = { name, geo: Boolean(matches[1]), multi: isNaN(matches[2]) ? false : Number(matches[2]) }; // Parse remainder as JSON try { info.fields = JSON.parse(name.slice(json_offset)); } catch (err) { check(false, `Unexpected index name (invalid JSON): "${name}"`); } // Sanity check fields const validate_field = (f) => { check(Array.isArray(f), `Unexpected index name (invalid field): "${name}"`); f.forEach((s) => check(typeof s === 'string', `Unexpected index name (invalid field): "${name}"`)); }; check(Array.isArray(info.fields), `Unexpected index name (fields are not an array): "${name}"`); check((info.multi === false) || (info.multi < info.fields.length), `Unexpected index name (multi index out of bounds): "${name}"`); info.fields.forEach(validate_field); return info; }; const info_to_name = (info) => { let res = 'hz_'; if (info.geo) { res += 'geo_'; } if (info.multi !== false) { res += 'multi_' + info.multi + '_'; } res += JSON.stringify(info.fields); return res; }; const info_to_reql = (info) => { if (info.geo && (info.multi !== false)) { throw new Error('multi and geo cannot be specified on the same index'); } if (info.multi !== false) { const multi_field = info.fields[info.multi]; return (row) => row(multi_field).map((value) => info.fields.map((f, i) => { if (i === info.multi) { return value; } else { let res = row; f.forEach((field_name) => { res = res(field_name); }); return res; } })); } else { return (row) => info.fields.map((f) => { let res = row; f.forEach((field_name) => { res = res(field_name); }); return res; }); } }; const compare_fields = (a, b) => { if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; ++i) { if (a[i] !== b[i]) { return false; } } return true; }; class Index { constructor(name, table, conn) { logger.debug(`${table} index registered: ${name}`); const info = name_to_info(name); this.name = name; this.geo = info.geo; // true or false this.multi = info.multi; // false or the offset of the multi field this.fields = info.fields; // array of fields or nested field paths this._waiters = [ ]; this._result = null; if (this.geo) { logger.warn(`Unsupported index (geo): ${this.name}`); } else if (this.multi !== false) { logger.warn(`Unsupported index (multi): ${this.name}`); } if (name !== primary_index_name) { table.indexWait(name).run(conn).then(() => { logger.debug(`${table} index ready: ${name}`); this._result = true; this._waiters.forEach((w) => w()); this._waiters = [ ]; }).catch((err) => { this._result = err; this._waiters.forEach((w) => w(err)); this._waiters = [ ]; }); } else { logger.debug(`${table} index ready: ${name}`); this._result = true; } } close() { this._waiters.forEach((w) => w(new Error('index deleted'))); this._waiters = [ ]; } ready() { return this._result === true; } on_ready(done) { if (this._result === true) { done(); } else if (this._result) { done(this._result); } else { this._waiters.push(done); } } // `fuzzy_fields` may be in any order at the beginning of the index. // These must be immediately followed by `ordered_fields` in the exact // order given. There may be no other fields present in the index // (because the absence of a field would mean that row is not indexed). // `fuzzy_fields` may overlap with `ordered_fields`. is_match(fuzzy_fields, ordered_fields) { // TODO: multi index matching if (this.geo || this.multi !== false) { return false; } if (this.fields.length > fuzzy_fields.length + ordered_fields.length || this.fields.length < fuzzy_fields.length || this.fields.length < ordered_fields.length) { return false; } for (let i = 0; i < fuzzy_fields.length; ++i) { let found = false; for (let j = 0; j < fuzzy_fields.length && !found; ++j) { found = compare_fields(fuzzy_fields[i], this.fields[j]); } if (!found) { return false; } } for (let i = 0; i < ordered_fields.length; ++i) { const pos = this.fields.length - ordered_fields.length + i; if (pos < 0 || !compare_fields(ordered_fields[i], this.fields[pos])) { return false; } } return true; } } module.exports = { Index, primary_index_name, name_to_info, info_to_name, info_to_reql }; ================================================ FILE: server/src/metadata/metadata.js ================================================ 'use strict'; const error = require('../error'); const logger = require('../logger'); const Group = require('../permissions/group').Group; const Collection = require('./collection').Collection; const version_field = require('../endpoint/writes').version_field; const utils = require('../utils'); const r = require('rethinkdb'); const metadata_version = [ 2, 0, 0 ]; const create_collection = (db, name, conn) => r.db(db).table('hz_collections').get(name).replace({ id: name }).do((res) => r.branch( res('errors').ne(0), r.error(res('first_error')), res('inserted').eq(1), r.db(db).tableCreate(name), res ) ).run(conn); const initialize_metadata = (db, conn) => r.branch(r.dbList().contains(db), null, r.dbCreate(db)).run(conn) .then(() => Promise.all([ 'hz_collections', 'hz_users_auth', 'hz_groups' ].map((table) => r.branch(r.db(db).tableList().contains(table), { }, r.db(db).tableCreate(table)) .run(conn)))) .then(() => r.db(db).table('hz_collections').wait({ timeout: 30 }).run(conn)) .then(() => Promise.all([ r.db(db).tableList().contains('users').not().run(conn).then(() => create_collection(db, 'users', conn)), r.db(db).table('hz_collections') .insert({ id: 'hz_metadata', version: metadata_version }) .run(conn), ]) ); class Metadata { constructor(project_name, conn, clients, auto_create_collection, auto_create_index) { this._db = project_name; this._conn = conn; this._clients = clients; this._auto_create_collection = auto_create_collection; this._auto_create_index = auto_create_index; this._closed = false; this._ready = false; this._collections = new Map(); this._groups = new Map(); this._collection_feed = null; this._group_feed = null; this._index_feed = null; this._ready_promise = Promise.resolve().then(() => { logger.debug('checking rethinkdb version'); return r.db('rethinkdb').table('server_status').nth(0)('process')('version').run(this._conn) .then((res) => utils.rethinkdb_version_check(res)); }).then(() => { const old_metadata_db = `${this._db}_internal`; return r.dbList().contains(old_metadata_db).run(this._conn).then((has_old_db) => { if (has_old_db) { throw new Error('The Horizon metadata appears to be from v1.x because ' + `the "${old_metadata_db}" database exists. Please use ` + '`hz migrate` to convert your metadata to the new format.'); } }); }).then(() => { logger.debug('checking for internal tables'); if (this._auto_create_collection) { return initialize_metadata(this._db, this._conn); } else { return r.dbList().contains(this._db).run(this._conn).then((has_db) => { if (!has_db) { throw new Error(`The database ${this._db} does not exist. ` + 'Run `hz schema apply` to initialize the database, ' + 'then start the Horizon server.'); } }); } }).then(() => { logger.debug('waiting for internal tables'); return r.expr([ 'hz_collections', 'hz_users_auth', 'hz_groups', 'users' ]) .forEach((table) => r.db(this._db).table(table).wait({ timeout: 30 })).run(this._conn); }).then(() => { logger.debug('syncing metadata changefeeds'); const group_changefeed = r.db(this._db) .table('hz_groups') .changes({ squash: true, includeInitial: true, includeStates: true, includeTypes: true }) .run(this._conn).then((res) => { if (this._closed) { res.close().catch(() => { }); throw new Error('This metadata instance has been closed.'); } return new Promise((resolve, reject) => { this._group_feed = res; this._group_feed.eachAsync((change) => { if (change.type === 'state') { if (change.state === 'ready') { logger.info('Groups metadata synced.'); resolve(); } } else if (change.type === 'initial' || change.type === 'add' || change.type === 'change') { const group = new Group(change.new_val); this._groups.set(group.name, group); this._clients.forEach((c) => c.group_changed(group.name)); } else if (change.type === 'uninitial' || change.type === 'remove') { this._groups.delete(change.old_val.id); this._clients.forEach((c) => c.group_changed(change.old_val.id)); } }).catch(reject); }); }); const collection_changefeed = r.db(this._db) .table('hz_collections') .filter((row) => row('id').match('^hz_').not()) .changes({ squash: false, includeInitial: true, includeStates: true, includeTypes: true }) .run(this._conn).then((res) => { if (this._closed) { res.close().catch(() => { }); throw new Error('This metadata instance has been closed.'); } return new Promise((resolve, reject) => { this._collection_feed = res; this._collection_feed.eachAsync((change) => { if (change.type === 'state') { if (change.state === 'ready') { logger.info('Collections metadata synced.'); resolve(); } } else if (change.type === 'initial' || change.type === 'add' || change.type === 'change') { const collection_name = change.new_val.id; let collection = this._collections.get(collection_name); if (!collection) { collection = new Collection(this._db, collection_name); this._collections.set(collection_name, collection); } collection._register(); } else if (change.type === 'uninitial' || change.type === 'remove') { const collection = this._collections.get(change.old_val.id); if (collection) { collection._unregister(); if (collection._is_safe_to_remove()) { this._collections.delete(change.old_val.id); collection._close(); } } } }).catch(reject); }); }); const index_changefeed = r.db('rethinkdb') .table('table_config') .filter((row) => r.and(row('db').eq(this._db), row('name').match('^hz_').not())) .map((row) => ({ id: row('id'), name: row('name'), indexes: row('indexes').filter((idx) => idx.match('^hz_')), })) .changes({ squash: true, includeInitial: true, includeStates: true, includeTypes: true }) .run(this._conn).then((res) => { if (this._closed) { res.close().catch(() => { }); throw new Error('This metadata instance has been closed.'); } return new Promise((resolve, reject) => { this._index_feed = res; this._index_feed.eachAsync((change) => { if (change.type === 'state') { if (change.state === 'ready') { logger.info('Index metadata synced.'); resolve(); } } else if (change.type === 'initial' || change.type === 'add' || change.type === 'change') { const collection_name = change.new_val.name; const table_id = change.new_val.id; let collection = this._collections.get(collection_name); if (!collection) { collection = new Collection(this._db, collection_name); this._collections.set(collection_name, collection); } collection._update_table(table_id, change.new_val.indexes, this._conn); } else if (change.type === 'uninitial' || change.type === 'remove') { const collection = this._collections.get(change.old_val.name); if (collection) { collection._update_table(change.old_val.id, null, this._conn); if (collection._is_safe_to_remove()) { this._collections.delete(collection); collection._close(); } } } }).catch(reject); }); }); return Promise.all([ group_changefeed, collection_changefeed, index_changefeed ]); }).then(() => { logger.debug('adding admin user'); // Ensure that the admin user and group exists return Promise.all([ r.db(this._db).table('users').get('admin') .replace((old_row) => r.branch(old_row.eq(null), { id: 'admin', groups: [ 'admin' ], [version_field]: 0, }, old_row), { returnChanges: 'always' })('changes')(0) .do((res) => r.branch(res('new_val').eq(null), r.error(res('error')), res('new_val'))).run(this._conn), r.db(this._db).table('hz_groups').get('admin') .replace((old_row) => r.branch(old_row.eq(null), { id: 'admin', rules: { carte_blanche: { template: 'any()' } }, [version_field]: 0, }, old_row), { returnChanges: 'always' })('changes')(0) .do((res) => r.branch(res('new_val').eq(null), r.error(res('error')), res('new_val'))).run(this._conn), ]); }).then(() => { logger.debug('metadata sync complete'); this._ready = true; return this; }); this._ready_promise.catch(() => { this.close(); }); } close() { this._closed = true; this._ready = false; if (this._group_feed) { this._group_feed.close().catch(() => { }); } if (this._collection_feed) { this._collection_feed.close().catch(() => { }); } if (this._index_feed) { this._index_feed.close().catch(() => { }); } this._collections.forEach((collection) => collection._close()); this._collections.clear(); } is_ready() { return this._ready; } ready() { return this._ready_promise; } collection(name) { if (name.indexOf('hz_') === 0) { throw new Error(`Collection "${name}" is reserved for internal use ` + 'and cannot be used in requests.'); } const collection = this._collections.get(name); if (collection === undefined) { throw new error.CollectionMissing(name); } if (!collection._get_table().ready()) { throw new error.CollectionNotReady(collection); } return collection; } handle_error(err, done) { logger.debug(`Handling error: ${err.message}`); try { if (err instanceof error.CollectionNotReady) { return err.collection._on_ready(done); } else if (err instanceof error.IndexNotReady) { return err.index.on_ready(done); } else if (this._auto_create_collection && (err instanceof error.CollectionMissing)) { logger.warn(`Auto-creating collection: ${err.name}`); return this.create_collection(err.name, done); } else if (this._auto_create_index && (err instanceof error.IndexMissing)) { logger.warn(`Auto-creating index on collection "${err.collection.name}": ` + `${JSON.stringify(err.fields)}`); return err.collection._create_index(err.fields, this._conn, done); } done(err); } catch (new_err) { logger.debug(`Error when handling error: ${new_err.message}`); done(new_err); } } create_collection(name, done) { error.check(this._collections.get(name) === undefined, `Collection "${name}" already exists.`); const collection = new Collection(this._db, name); this._collections.set(name, collection); create_collection(this._db, name, this._conn).then((res) => { error.check(!res.error, `Collection "${name}" creation failed: ${res.error}`); logger.warn(`Collection created: "${name}"`); collection._on_ready(done); }).catch((err) => { if (collection._is_safe_to_remove()) { this._collections.delete(name); collection._close(); } done(err); }); } get_user_feed(id) { return r.db(this._db).table('users').get(id) .changes({ includeInitial: true, squash: true }) .run(this._conn); } get_group(group_name) { return this._groups.get(group_name); } connection() { return this._conn; } } module.exports = { Metadata, create_collection, initialize_metadata }; ================================================ FILE: server/src/metadata/table.js ================================================ 'use strict'; const error = require('../error'); const index = require('./index'); const logger = require('../logger'); const r = require('rethinkdb'); class Table { constructor(reql_table, conn) { this.table = reql_table; this.indexes = new Map(); this._waiters = [ ]; this._result = null; this.table .wait({ waitFor: 'all_replicas_ready' }) .run(conn) .then(() => { this._result = true; this._waiters.forEach((w) => w()); this._waiters = [ ]; }).catch((err) => { this._result = err; this._waiters.forEach((w) => w(err)); this._waiters = [ ]; }); } close() { this._waiters.forEach((w) => w(new Error('collection deleted'))); this._waiters = [ ]; this.indexes.forEach((i) => i.close()); this.indexes.clear(); } ready() { return this._result === true; } on_ready(done) { if (this._result === true) { done(); } else if (this._result) { done(this._result); } else { this._waiters.push(done); } } update_indexes(indexes, conn) { logger.debug(`${this.table} indexes changed, reevaluating`); // Initialize the primary index, which won't show up in the changefeed indexes.push(index.primary_index_name); const new_index_map = new Map(); indexes.forEach((name) => { try { const old_index = this.indexes.get(name); const new_index = new index.Index(name, this.table, conn); if (old_index) { // Steal any waiters from the old index new_index._waiters = old_index._waiters; old_index._waiters = [ ]; } new_index_map.set(name, new_index); } catch (err) { logger.warn(`${err}`); } }); this.indexes.forEach((i) => i.close()); this.indexes = new_index_map; logger.debug(`${this.table} indexes updated`); } // TODO: support geo and multi indexes create_index(fields, conn, done) { const info = { geo: false, multi: false, fields }; const index_name = index.info_to_name(info); error.check(!this.indexes.get(index_name), 'index already exists'); const success = () => { // Create the Index object now so we don't try to create it again before the // feed notifies us of the index creation const new_index = new index.Index(index_name, this.table, conn); this.indexes.set(index_name, new_index); // TODO: shouldn't this be done before we go async? return new_index.on_ready(done); }; this.table.indexCreate(index_name, index.info_to_reql(info), { geo: info.geo, multi: (info.multi !== false) }) .run(conn) .then(success) .catch((err) => { if (err instanceof r.Error.ReqlError && err.msg.indexOf('already exists') !== -1) { success(); } else { done(err); } }); } // Returns a matching (possibly compound) index for the given fields // fuzzy_fields and ordered_fields should both be arrays get_matching_index(fuzzy_fields, ordered_fields) { if (fuzzy_fields.length === 0 && ordered_fields.length === 0) { return this.indexes.get(index.primary_index_name); } let match; for (const i of this.indexes.values()) { if (i.is_match(fuzzy_fields, ordered_fields)) { if (i.ready()) { return i; } else if (!match) { match = i; } } } return match; } } module.exports = { Table }; ================================================ FILE: server/src/permissions/group.js ================================================ 'use strict'; const Rule = require('./rule').Rule; class Group { constructor(row_data) { this.name = row_data.id; this.rules = Object.keys(row_data.rules).map((name) => new Rule(name, row_data.rules[name])); } } module.exports = { Group }; ================================================ FILE: server/src/permissions/rule.js ================================================ 'use strict'; const Template = require('./template').Template; const Validator = require('./validator').Validator; class Rule { constructor(name, info) { this._name = name; this._template = new Template(info.template); if (info.validator) { this._validator = new Validator(info.validator); } } is_match(query, context) { return this._template.is_match(query, context); } is_valid() { if (!this._validator) { return true; } return this._validator.is_valid.apply(this._validator, arguments); } } class Ruleset { constructor() { this.clear(); } clear() { this._rules = [ ]; } empty() { return this._rules.length === 0; } update(rules) { this._rules = rules; } validation_required() { for (const rule of this._rules) { if (!rule._validator) { return false; } } return true; } // Check that a query passes at least one rule in a set // Returns the matching rule or undefined if no rules match // Variadic - extra arguments are passed down to the validator validate() { for (const rule of this._rules) { if (rule.is_valid.apply(rule, arguments)) { return rule; } } } } // The any_rule is used when permissions are disabled - it allows all queries const any_rule = new Rule('permissions_disabled', { template: 'any()' }); module.exports = { Rule, Ruleset, any_rule }; ================================================ FILE: server/src/permissions/template.js ================================================ 'use strict'; const check = require('../error').check; const remake_error = require('../utils').remake_error; const ast = require('@horizon/client/lib/ast'); const validIndexValue = require('@horizon/client/lib/util/valid-index-value').default; const vm = require('vm'); let template_compare; class Any { constructor(values) { this._values = values || [ ]; } matches(value, context) { if (value === undefined) { return false; } else if (this._values.length === 0) { return true; } for (const item of this._values) { if (template_compare(value, item, context)) { return true; } } return false; } } // This works the same as specifying a literal object in a template, except that // unspecified key/value pairs are allowed. class AnyObject { constructor(obj) { this._obj = obj || { }; } matches(value, context) { if (value === null || typeof value !== 'object') { return false; } for (const key in this._obj) { if (!template_compare(value[key], this._obj[key], context)) { return false; } } return true; } } // This matches an array where each item matches at least one of the values // specified at construction. class AnyArray { constructor(values) { this._values = values || [ ]; } matches(value, context) { if (!Array.isArray(value)) { return false; } for (const item of value) { let match = false; for (const template of this._values) { if (template_compare(item, template, context)) { match = true; break; } } if (!match) { return false; } } return true; } } class UserId { } const wrap_write = (query, docs) => { if (docs instanceof AnyArray || Array.isArray(docs)) { query.data = docs; } else { query.data = [ docs ]; } return query; }; const wrap_remove = (doc) => { if (validIndexValue(doc)) { return { id: doc }; } return doc; }; // Add helper methods to match any subset of the current query for reads or writes ast.TermBase.prototype.anyRead = function() { return this._sendRequest(new Any([ 'query', 'subscribe' ]), new AnyObject(this._query)); }; ast.Collection.prototype.anyWrite = function() { let docs = arguments; if (arguments.length === 0) { docs = new AnyArray(new Any()); } return this._sendRequest(new Any([ 'store', 'upsert', 'insert', 'replace', 'update', 'remove' ]), wrap_write(new AnyObject(this._query), docs)); }; // Monkey-patch the ast functions so we don't clobber certain things ast.TermBase.prototype.watch = function() { return this._sendRequest('subscribe', this._query); }; ast.TermBase.prototype.fetch = function() { return this._sendRequest('query', this._query); }; ast.Collection.prototype.store = function(docs) { return this._sendRequest('store', wrap_write(this._query, docs)); }; ast.Collection.prototype.upsert = function(docs) { return this._sendRequest('upsert', wrap_write(this._query, docs)); }; ast.Collection.prototype.insert = function(docs) { return this._sendRequest('insert', wrap_write(this._query, docs)); }; ast.Collection.prototype.replace = function(docs) { return this._sendRequest('replace', wrap_write(this._query, docs)); }; ast.Collection.prototype.update = function(docs) { return this._sendRequest('update', wrap_write(this._query, docs)); }; ast.Collection.prototype.remove = function(doc) { return this._sendRequest('remove', wrap_write(this._query, wrap_remove(doc))); }; ast.Collection.prototype.removeAll = function(docs) { return this._sendRequest('remove', wrap_write(this._query, docs.map((doc) => wrap_remove(doc)))); }; const env = { collection: (name) => new ast.Collection((type, options) => ({ request_id: new Any(), type: Array.isArray(type) ? new Any(type) : type, options }), name, false), any: function() { return new Any(Array.from(arguments)); }, anyObject: function(obj) { return new AnyObject(obj); }, anyArray: function() { return new AnyArray(Array.from(arguments)); }, userId: function() { return new UserId(); }, }; const make_template = (str) => { try { const sandbox = Object.assign({}, env); return vm.runInNewContext(str, sandbox); } catch (err) { throw remake_error(err); } }; // eslint-disable-next-line prefer-const template_compare = (query, template, context) => { if (template === undefined) { return false; } else if (template instanceof Any || template instanceof AnyObject || template instanceof AnyArray) { if (!template.matches(query, context)) { return false; } } else if (template instanceof UserId) { if (query !== context.id) { return false; } } else if (template === null) { if (query !== null) { return false; } } else if (Array.isArray(template)) { if (!Array.isArray(query) || template.length !== query.length) { return false; } for (let i = 0; i < template.length; ++i) { if (!template_compare(query[i], template[i], context)) { return false; } } } else if (typeof template === 'object') { if (typeof query !== 'object') { return false; } for (const key in query) { if (!template_compare(query[key], template[key], context)) { return false; } } // Make sure all template keys were handled for (const key in template) { if (query[key] === undefined) { return false; } } } else if (template !== query) { return false; } return true; }; const incomplete_template_message = (str) => `Incomplete template "${str}", ` + 'consider adding ".fetch()", ".watch()", ".anyRead()", or ".anyWrite()"'; class Template { constructor(str) { this._value = make_template(str); check(this._value !== null, `Invalid template: ${str}`); check(!Array.isArray(this._value), `Invalid template: ${str}`); check(typeof this._value === 'object', `Invalid template: ${str}`); if (!(this._value instanceof Any) && !(this._value instanceof AnyObject)) { if (this._value.request_id === undefined && this._value.type === undefined && this._value.options === undefined && this._value.anyRead) { this._value = this._value.anyRead(); } check(this._value.request_id !== undefined, incomplete_template_message(str)); check(this._value.type !== undefined, incomplete_template_message(str)); check(this._value.options !== undefined, incomplete_template_message(str)); } } is_match(raw_query, context) { return template_compare(raw_query, this._value, context); } } module.exports = { Template }; ================================================ FILE: server/src/permissions/validator.js ================================================ 'use strict'; const check = require('../error').check; const logger = require('../logger'); const remake_error = require('../utils').remake_error; const vm = require('vm'); class Validator { constructor(str) { try { this._fn = vm.runInNewContext(str, {}); } catch (err) { throw remake_error(err); } check(typeof this._fn === 'function'); } is_valid() { try { return this._fn.apply(this._fn, arguments); } catch (err) { // We don't want to pass the error message on to the user because it might leak // information about the data. logger.error(`Exception in validator function: ${err.stack}`); throw new Error('Validation error'); } } } module.exports = { Validator }; ================================================ FILE: server/src/reql_connection.js ================================================ 'use strict'; const check = require('./error').check; const logger = require('./logger'); const Metadata = require('./metadata/metadata').Metadata; const r = require('rethinkdb'); const default_user = 'admin'; const default_pass = ''; class ReqlConnection { constructor(host, port, db, auto_create_collection, auto_create_index, user, pass, connect_timeout, interruptor) { this._rdb_options = { host, port, db, user: user || default_user, password: pass || default_pass, timeout: connect_timeout || null, }; this._auto_create_collection = auto_create_collection; this._auto_create_index = auto_create_index; this._clients = new Set(); this._reconnect_delay = 0; this._retry_timer = null; interruptor.catch((err) => { if (this._retry_timer) { clearTimeout(this._retry_timer); } this._clients.forEach((client) => client.close({ error: err.message })); this._clients.clear(); this._interrupted_err = err; this._reconnect(); // This won't actually reconnect, but will do all the cleanup }); logger.info('Connecting to RethinkDB: ' + `${this._rdb_options.user} @ ${this._rdb_options.host}:${this._rdb_options.port}`); this._ready_promise = this._reconnect(); } _reconnect() { if (this._conn) { this._conn.removeAllListeners('close'); this._conn.close(); } if (this._metadata) { this._metadata.close(); } this._conn = null; this._metadata = null; this._clients.forEach((client) => client.close({ error: 'Connection to the database was lost.' })); this._clients.clear(); if (this._interrupted_err) { return Promise.reject(this._interrupted_err); } else if (!this._retry_timer) { return new Promise((resolve) => { this._retry_timer = setTimeout(() => resolve(this._init_connection()), this._reconnect_delay); this._reconnect_delay = Math.min(this._reconnect_delay + 100, 1000); }); } } _init_connection() { this._retry_timer = null; return r.connect(this._rdb_options).then((conn) => { if (this._interrupted_err) { return Promise.reject(this._interrupted_err); } this._conn = conn; logger.debug('Connection to RethinkDB established.'); return new Metadata(this._rdb_options.db, conn, this._clients, this._auto_create_collection, this._auto_create_index).ready(); }).then((metadata) => { logger.info('Connection to RethinkDB ready: ' + `${this._rdb_options.user} @ ${this._rdb_options.host}:${this._rdb_options.port}`); this._metadata = metadata; this._reconnect_delay = 0; this._conn.once('close', () => { logger.error('Lost connection to RethinkDB.'); this._reconnect(); }); // This is to avoid EPIPE errors - handling is done by the 'close' listener this._conn.on('error', () => { }); return this; }).catch((err) => { logger.error(`Connection to RethinkDB terminated: ${err}`); logger.debug(`stack: ${err.stack}`); return this._reconnect(); }); } is_ready() { return Boolean(this._conn); } ready() { return this._ready_promise; } connection() { check(this.is_ready(), 'Connection to the database is down.'); return this._conn; } metadata() { check(this.is_ready(), 'Connection to the database is down.'); check(this._metadata, 'Connection to the database is initializing.'); return this._metadata; } } module.exports = { ReqlConnection }; ================================================ FILE: server/src/request.js ================================================ 'use strict'; const logger = require('./logger'); const rule = require('./permissions/rule'); class Request { constructor(raw_request, endpoint, client) { this._raw_request = raw_request; this._ruleset = new rule.Ruleset(); this._endpoint = endpoint; this._client = client; this.evaluate_rules(); } evaluate_rules() { if (this._client._permissions_enabled) { const metadata = this._client._metadata; const user_info = this._client.user_info; const matching_rules = [ ]; for (const group_name of user_info.groups) { const group = metadata.get_group(group_name); if (group !== undefined) { for (const r of group.rules) { if (r.is_match(this._raw_request, user_info)) { matching_rules.push(r); } } } } this._ruleset.update(matching_rules); } else { this._ruleset.update([ rule.any_rule ]); } } run() { let complete = false; try { if (this._ruleset.empty()) { throw new Error('Operation not permitted.'); } this._cancel_cb = this._endpoint(this._raw_request, this._client.user_info, this._ruleset, this._client._metadata, (res) => { this._client.send_response(this._raw_request, res); }, (res) => { // Only send something the first time 'done' is called if (!complete) { complete = true; if (res instanceof Error) { this.handle_error(res); } else if (res) { this._client.send_response(this._raw_request, res); } this._client.remove_request(this._raw_request); } }); } catch (err) { this.handle_error(err); } } close() { this._ruleset.clear(); if (this._cancel_cb) { this._cancel_cb(); } } handle_error(err) { logger.debug(`Error on request ${this._raw_request.request_id}:\n${err.stack}`); // Ignore errors for disconnected clients if (this._client.is_open()) { this._client._metadata.handle_error(err, (inner_err) => { if (inner_err) { this._client.send_error(this._raw_request, inner_err); } else { setImmediate(() => this.run()); } }); } } } module.exports = { Request }; ================================================ FILE: server/src/schema/horizon_protocol.js ================================================ 'use strict'; const Joi = require('joi'); const handshake = Joi.object().keys({ request_id: Joi.number().required(), method: Joi.only('token', 'anonymous', 'unauthenticated').required(), token: Joi.string().required() .when('method', { is: Joi.not('token').required(), then: Joi.forbidden() }), }).unknown(false); const read = Joi.alternatives().try( Joi.object().keys({ collection: Joi.string().token().required(), find: Joi.object().min(1).unknown(true).required(), }).unknown(false), Joi.object().keys({ collection: Joi.string().token().required(), limit: Joi.number().integer().greater(-1).optional() .when('find', { is: Joi.any().required(), then: Joi.forbidden() }), order: Joi.array().ordered( Joi.array().items(Joi.string()).min(1).unique().label('fields').required(), Joi.string().valid('ascending', 'descending').label('direction').required()).optional() .when('find_all', { is: Joi.array().min(2).required(), then: Joi.forbidden() }), above: Joi.array().ordered( Joi.object().length(1).unknown(true).label('value').required(), Joi.string().valid('open', 'closed').label('bound_type').required()).optional() .when('find_all', { is: Joi.array().min(2).required(), then: Joi.forbidden() }), below: Joi.array().ordered( Joi.object().length(1).unknown(true).label('value').required(), Joi.string().valid('open', 'closed').label('bound_type').required()).optional() .when('find_all', { is: Joi.array().min(2).required(), then: Joi.forbidden() }), find_all: Joi.array().items(Joi.object().min(1).label('item').unknown(true)).min(1).optional(), }).unknown(false) ); const write_id_optional = Joi.object({ timeout: Joi.number().integer().greater(-1).optional().default(null), collection: Joi.string().token().required(), data: Joi.array().min(1).items(Joi.object({ id: Joi.any().optional(), }).unknown(true)).required(), }).unknown(false); const write_id_required = Joi.object({ timeout: Joi.number().integer().greater(-1).optional().default(null), collection: Joi.string().token().required(), data: Joi.array().min(1).items(Joi.object({ id: Joi.any().required(), }).unknown(true)).required(), }).unknown(false); const request = Joi.object({ request_id: Joi.number().required(), type: Joi.string().required(), options: Joi.object().required() .when('type', { is: Joi.string().only('end_subscription'), then: Joi.forbidden() }) .when('type', { is: Joi.string().only('keepalive'), then: Joi.forbidden() }), }).unknown(false); module.exports = { handshake, request, query: read, subscribe: read, insert: write_id_optional, store: write_id_optional, upsert: write_id_optional, update: write_id_required, replace: write_id_required, remove: write_id_required, }; ================================================ FILE: server/src/schema/server_options.js ================================================ 'use strict'; const Joi = require('joi'); const server = Joi.object({ project_name: Joi.string().default('horizon'), rdb_host: Joi.string().hostname().default('localhost'), rdb_port: Joi.number().greater(0).less(65536).default(28015), auto_create_collection: Joi.boolean().default(false), auto_create_index: Joi.boolean().default(false), permissions: Joi.boolean().default(true), path: Joi.string().default('/horizon'), auth: Joi.object().default({ }), access_control_allow_origin: Joi.string().allow('').default(''), rdb_user: Joi.string().allow(null), rdb_password: Joi.string().allow(null), rdb_timeout: Joi.number().allow(null), max_connections: Joi.number().allow(null), }).unknown(false); const auth = Joi.object({ success_redirect: Joi.string().default('/'), failure_redirect: Joi.string().default('/'), duration: Joi.alternatives(Joi.string(), Joi.number().positive()).default('1d'), create_new_users: Joi.boolean().default(true), new_user_group: Joi.string().default('authenticated'), token_secret: Joi.string().allow(null), allow_anonymous: Joi.boolean().default(false), allow_unauthenticated: Joi.boolean().default(false), }).unknown(false); module.exports = { server, auth }; ================================================ FILE: server/src/server.js ================================================ 'use strict'; const Auth = require('./auth').Auth; const make_client = require('./client').make_client; const ReqlConnection = require('./reql_connection').ReqlConnection; const logger = require('./logger'); const options_schema = require('./schema/server_options').server; const getType = require('mime-types').contentType; // TODO: dynamically serve different versions of the horizon // library. Minified, Rx included etc. const horizon_client_path = require.resolve('@horizon/client/dist/horizon'); const endpoints = { insert: require('./endpoint/insert'), query: require('./endpoint/query'), remove: require('./endpoint/remove'), replace: require('./endpoint/replace'), store: require('./endpoint/store'), subscribe: require('./endpoint/subscribe'), update: require('./endpoint/update'), upsert: require('./endpoint/upsert'), }; const assert = require('assert'); const fs = require('fs'); const Joi = require('joi'); const path = require('path'); const url = require('url'); const websocket = require('ws'); const protocol_name = 'rethinkdb-horizon-v0'; const accept_protocol = (protocols, cb) => { if (protocols.findIndex((x) => x === protocol_name) !== -1) { cb(true, protocol_name); } else { logger.debug(`Rejecting client without "${protocol_name}" protocol (${protocols}).`); cb(false, null); } }; const serve_file = (file_path, res) => { fs.access(file_path, fs.R_OK | fs.F_OK, (exists) => { if (exists) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end(`Client library not found\n`); } else { fs.readFile(file_path, 'binary', (err, file) => { if (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end(`${err}\n`); } else { const type = getType(path.extname(file_path)) || false; if (type) { res.writeHead(200, { 'Content-Type': type }); } else { res.writeHead(200); } res.end(file, 'binary'); } }); } }); }; class Server { constructor(http_servers, user_opts) { const opts = Joi.attempt(user_opts || { }, options_schema); this._path = opts.path; this._name = opts.project_name; this._max_connections = opts.max_connections; this._permissions_enabled = opts.permissions; this._auth_methods = { }; this._request_handlers = new Map(); this._http_handlers = new Map(); this._ws_servers = [ ]; this._close_promise = null; this._interruptor = new Promise((resolve, reject) => { this._interrupt = reject; }); try { this._reql_conn = new ReqlConnection(opts.rdb_host, opts.rdb_port, opts.project_name, opts.auto_create_collection, opts.auto_create_index, opts.rdb_user || null, opts.rdb_password || null, opts.rdb_timeout || null, this._interruptor); this._auth = new Auth(this, opts.auth); for (const key in endpoints) { this.add_request_handler(key, endpoints[key].run); } const verify_client = (info, cb) => { // Reject connections if we aren't synced with the database if (!this._reql_conn.is_ready()) { cb(false, 503, 'Connection to the database is down.'); } else { cb(true); } }; const ws_options = { handleProtocols: accept_protocol, allowRequest: verify_client, path: this._path }; const add_websocket = (server) => { const ws_server = new websocket.Server(Object.assign({ server }, ws_options)) .on('error', (error) => logger.error(`Websocket server error: ${error}`)) .on('connection', (socket) => make_client(socket, this)); this._ws_servers.push(ws_server); }; const path_replace = new RegExp('^' + this._path + '/'); const add_http_listener = (server) => { // TODO: this doesn't play well with a user removing listeners (or maybe even `once`) const extant_listeners = server.listeners('request').slice(0); server.removeAllListeners('request'); server.on('request', (req, res) => { const req_path = url.parse(req.url).pathname; if (req_path.indexOf(`${this._path}/`) === 0) { const sub_path = req_path.replace(path_replace, ''); const handler = this._http_handlers.get(sub_path); if (handler !== undefined) { logger.debug(`Handling HTTP request to horizon subpath: ${sub_path}`); return handler(req, res); } } if (extant_listeners.length === 0) { res.statusCode = 404; res.write('File not found.'); res.end(); } else { extant_listeners.forEach((l) => l.call(server, req, res)); } }); }; this.add_http_handler('horizon.js', (req, res) => { serve_file(horizon_client_path, res); }); this.add_http_handler('horizon.js.map', (req, res) => { serve_file(`${horizon_client_path}.map`, res); }); this.add_http_handler('auth_methods', (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': opts.access_control_allow_origin, }); res.end(JSON.stringify(this._auth_methods)); }); if (http_servers.forEach === undefined) { add_websocket(http_servers); add_http_listener(http_servers); } else { http_servers.forEach((s) => { add_websocket(s); add_http_listener(s); }); } } catch (err) { this._interrupt(err); throw err; } } add_request_handler(request_name, endpoint) { assert(endpoint !== undefined); assert(this._request_handlers.get(request_name) === undefined); this._request_handlers.set(request_name, endpoint); } get_request_handler(request) { return this._request_handlers.get(request.type); } remove_request_handler(request_name) { return this._request_handlers.delete(request_name); } add_http_handler(sub_path, handler) { logger.debug(`Added HTTP handler at ${this._path}/${sub_path}`); assert.notStrictEqual(handler, undefined); assert.strictEqual(this._http_handlers.get(sub_path), undefined); this._http_handlers.set(sub_path, handler); } remove_http_handler(sub_path) { return this._http_handlers.delete(sub_path); } add_auth_provider(provider, options) { assert(provider.name); assert(options.path); assert.strictEqual(this._auth_methods[provider.name], undefined); this._auth_methods[provider.name] = `${this._path}/${options.path}`; provider(this, options); } ready() { return this._reql_conn.ready().then(() => this); } close() { if (!this._close_promise) { this._interrupt(new Error('Horizon server is shutting down.')); this._close_promise = Promise.all([ Promise.all(this._ws_servers.map((s) => new Promise((resolve) => { s.close(resolve); }))), this._reql_conn.ready().catch(() => { }), ]); } return this._close_promise; } } module.exports = { Server, protocol: protocol_name, }; ================================================ FILE: server/src/utils.js ================================================ 'use strict'; const MIN_VERSION = [ 2, 3, 1 ]; // Recursive version compare, could be flatter but opted for instant return if // comparison is greater rather than continuing to compare to end. const version_compare = (actual, minimum) => { for (let i = 0; i < minimum.length; ++i) { if (actual[i] > minimum[i]) { return true; } else if (actual[i] < minimum[i]) { return false; } } return true; }; // Check that RethinkDB matches version requirements const rethinkdb_version_check = (version_string) => { const rethinkdb_version_regex = /^rethinkdb (\d+)\.(\d+)\.(\d+)/i; const matches = rethinkdb_version_regex.exec(version_string); if (matches) { // Convert strings to ints and remove first match const versions = matches.slice(1).map((val) => parseInt(val)); if (!version_compare(versions, MIN_VERSION)) { throw new Error(`RethinkDB (${versions.join('.')}) is below required version ` + `(${MIN_VERSION.join('.')}) for use with Horizon.`); } } else { throw new Error('Unable to determine RethinkDB version, check ' + `RethinkDB is >= ${MIN_VERSION.join('.')}.`); } }; // Used when evaluating things in a different VM context - the errors // thrown from there will not evaluate as `instanceof Error`, so we recreate them. const remake_error = (err) => { const new_err = new Error(err.message || 'Unknown error when evaluating template.'); new_err.stack = err.stack || new_err.stack; throw new_err; }; module.exports = { rethinkdb_version_check, remake_error, }; ================================================ FILE: server/test/http_tests.js ================================================ 'use strict'; const horizon = require('../'); const assert = require('assert'); const child_process = require('child_process'); const fs = require('fs'); const http = require('http'); const https = require('https'); const path = require('path'); const all_tests = () => { [ 'http', 'https' ].forEach((transport) => { describe(transport, () => { let http_server, key_file, cert_file; before('Generate key and cert', (done) => { if (transport === 'http') { done(); return; } key_file = `key.${process.pid}.pem`; cert_file = `cert.${process.pid}.pem`; child_process.exec( `openssl req -x509 -nodes -batch -newkey rsa:2048 -keyout ${key_file} -days 1`, (err, stdout) => { assert.ifError(err); const cert_start = stdout.indexOf('-----BEGIN CERTIFICATE-----'); const cert_end = stdout.indexOf('-----END CERTIFICATE-----'); assert(cert_start !== -1 && cert_end !== -1); const cert = `${stdout.slice(cert_start, cert_end)}-----END CERTIFICATE-----\n`; fs.writeFile(cert_file, cert, done); }); }); after('Remove key and cert', () => { [ key_file, cert_file ].forEach((f) => { if (f) { fs.unlinkSync(f); } }); }); before('Start horizon server', (done) => { const four_o_four = (req, res) => { res.writeHeader(404); res.end(); }; if (transport === 'http') { http_server = new http.createServer(four_o_four); } else { http_server = new https.createServer({ key: fs.readFileSync(key_file), cert: fs.readFileSync(cert_file) }, four_o_four); } horizon(http_server, { auth: { token_secret: 'hunter2' } }); http_server.listen(0, done); }); after('Shutdown standalone horizon server', () => { http_server.close(); }); it('localhost/horizon/horizon.js', (done) => { require(transport).get({ host: 'localhost', port: http_server.address().port, path: '/horizon/horizon.js', rejectUnauthorized: false }, (res) => { const client_js = path.resolve(__dirname, '../node_modules/@horizon/client/dist/horizon.js'); const code = fs.readFileSync(client_js); let buffer = ''; assert.strictEqual(res.statusCode, 200); res.on('data', (delta) => { buffer += delta; }); res.on('end', () => (assert.equal(buffer, code), done())); }); }); }); }); }; const suite = (collection) => describe('Webserver', () => all_tests(collection)); module.exports = { suite }; ================================================ FILE: server/test/permissions.js ================================================ 'use strict'; const hz_rule = require('../src/permissions/rule'); const hz_validator = require('../src/permissions/validator'); const query = require('../src/endpoint/query'); const subscribe = require('../src/endpoint/subscribe'); const insert = require('../src/endpoint/insert'); const store = require('../src/endpoint/store'); const update = require('../src/endpoint/update'); const upsert = require('../src/endpoint/upsert'); const replace = require('../src/endpoint/replace'); const remove = require('../src/endpoint/remove'); const utils = require('./utils'); const assert = require('assert'); const r = require('rethinkdb'); const Rule = hz_rule.Rule; const Ruleset = hz_rule.Ruleset; const Validator = hz_validator.Validator; const make_request = (type, collection, options) => { if (collection !== null) { return { request_id: 5, type, options: Object.assign({ collection }, options) }; } else { return { request_id: 5, type, options }; } }; const context = { id: 3, groups: [ 'admin', 'default', 'authenticated' ] }; // Permit all rows const permitted_validator = ` (context) => { if (!context) { throw new Error('no context'); } return true; } `; // Forbid all rows const forbidden_validator = ` (context) => { if (!context) { throw new Error('no context'); } return false; } `; // Permit a row when the user's id is the last digit of the row's id const user_permitted_validator = ` (context, a, b) => { if (!context) { throw new Error('no context'); } const value = (a && a.id) || (b && b.id); return context.id === (value % 10); } `; const all_tests = (collection) => { describe('Template', () => { it('any', () => { const rule = new Rule('foo', { template: 'any()' }); const tests = [ { }, { type: 'query', options: { collection: 'test' } }, { fake: 'bar' }, { options: { } }, { type: 'query', options: { fake: 'baz' } } ]; for (const t of tests) { assert(rule.is_match(t, context)); assert(rule.is_valid()); } }); it('any read', () => { const rule = new Rule('foo', { template: 'collection(any()).anyRead()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('fake', 'test', { }), context)); assert(!rule.is_match(make_request('store', 'test', { }), context)); assert(!rule.is_match(make_request('query', null, { }), context)); assert(rule.is_match(make_request('query', 'fake', { }), context)); assert(rule.is_match(make_request('query', 'fake', { find: { } }), context)); assert(rule.is_match(make_request('query', 'test', { bar: 'baz' }), context)); assert(rule.is_match(make_request('query', 'test', { find_all: [ { }, { } ] }), context)); assert(!rule.is_match(make_request('subscribe', null, { }), context)); assert(rule.is_match(make_request('subscribe', 'fake', { }), context)); assert(rule.is_match(make_request('subscribe', 'fake', { find: { } }), context)); assert(rule.is_match(make_request('subscribe', 'test', { bar: 'baz' }), context)); assert(rule.is_match(make_request('subscribe', 'test', { find_all: [ { }, { } ] }), context)); }); it('any read with collection', () => { const rule = new Rule('foo', { template: 'collection("test").anyRead()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'fake', { }), context)); assert(rule.is_match(make_request('query', 'test', { }), context)); assert(rule.is_match(make_request('query', 'test', { }), context)); assert(rule.is_match(make_request('query', 'test', { }), context)); assert(rule.is_match(make_request('subscribe', 'test', { }), context)); assert(rule.is_match(make_request('subscribe', 'test', { }), context)); assert(rule.is_match(make_request('subscribe', 'test', { }), context)); assert(rule.is_match(make_request('subscribe', 'test', { }), context)); }); it('any read with order', () => { // TODO: allow for any number of fields in order const rule = new Rule('foo', { template: 'collection("test").order(any(), any()).anyRead()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'fake', { order: [ 'foo', 'ascending' ] }), context)); assert(!rule.is_match(make_request('query', 'test', { }), context)); assert(!rule.is_match(make_request('query', 'test', { order: [ 'baz' ] }), context)); assert(!rule.is_match(make_request('query', 'test', { order: [ 'baz', 'fake' ] }), context)); assert(!rule.is_match(make_request('query', 'test', { order: [ [ 'fake' ] ] }), context)); assert(rule.is_match(make_request('query', 'test', { order: [ [ 'foo' ], 'ascending' ] }), context)); assert(rule.is_match(make_request('query', 'test', { order: [ [ 'bar' ], 'descending' ] }), context)); assert(rule.is_match(make_request('query', 'test', { order: [ [ 'baz' ], 'fake' ] }), context)); assert(rule.is_match(make_request('query', 'test', { find: { }, order: [ [ 'baz' ], 'fake' ] }), context)); assert(rule.is_match(make_request('query', 'test', { find_all: [ { } ], order: [ [ 'baz' ], 'fake' ] }), context)); assert(rule.is_match(make_request('query', 'test', { fake: 'baz', order: [ [ 'baz' ], 'fake' ] }), context)); }); it('any read with find', () => { const rule = new Rule('foo', { template: 'collection("test").find(any()).anyRead()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'fake', { find: { } }), context)); assert(!rule.is_match(make_request('query', 'test', { }), context)); assert(rule.is_match(make_request('query', 'test', { find: { } }), context)); assert(rule.is_match(make_request('query', 'test', { find: { }, fake: 'baz' }), context)); }); it('any read with findAll', () => { // TODO: allow for any number of arguments in findAll const rule = new Rule('foo', { template: 'collection("test").findAll(any()).anyRead()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'fake', { find_all: { } }), context)); assert(!rule.is_match(make_request('query', 'test', { }), context)); assert(rule.is_match(make_request('query', 'test', { find_all: [ { } ] }), context)); assert(rule.is_match(make_request('query', 'test', { find_all: [ { } ], fake: 'baz' }), context)); }); it('single key in findAll', () => { const rule = new Rule('foo', { template: 'collection("test").findAll({ owner: userId() }).fetch()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'test', { find_all: { } }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: true }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { bar: 'baz' } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { owner: (context.id + 1) } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { owner: context.id, bar: 'baz' } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { owner: context.id }, { other: context.id } ] }), context)); assert(rule.is_match(make_request('query', 'test', { find_all: [ { owner: context.id } ] }), context)); }); it('multiple keys in findAll', () => { const rule = new Rule('foo', { template: 'collection("test").findAll({ owner: userId(), key: any() }).fetch()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'test', { find_all: { } }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: true }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { bar: 'baz' } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { owner: (context.id + 1) } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { owner: context.id, bar: 'baz' } ] }), context)); assert(rule.is_match(make_request('query', 'test', { find_all: [ { owner: context.id, key: 3 } ] }), context)); }); it('multiple items in findAll', () => { const rule = new Rule('foo', { template: 'collection("test").findAll({ a: userId() }, { b: userId() })' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'test', { find_all: { } }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: true }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { bar: 'baz' } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { a: (context.id + 1) }, { b: context.id } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { a: context.id, bar: 'baz' } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { a: context.id, b: context.id } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { a: context.id } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { b: context.id } ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find_all: [ { a: context.id }, { b: context.id, bar: 'baz' } ] }), context)); assert(rule.is_match(make_request('query', 'test', { find_all: [ { a: context.id }, { b: context.id } ] }), context)); }); it('collection fetch', () => { const rule = new Rule('foo', { template: 'collection("test").fetch()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'fake', { }), context)); assert(!rule.is_match(make_request('query', 'test', { bar: 'baz' }), context)); assert(!rule.is_match(make_request('query', 'test', { find: { id: 5 } }), context)); assert(rule.is_match(make_request('query', 'test', { }), context)); }); it('collection watch', () => { const rule = new Rule('foo', { template: 'collection("test").watch()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('subscribe', 'fake', { }), context)); assert(!rule.is_match(make_request('subscribe', 'test', { bar: 'baz' }), context)); assert(!rule.is_match(make_request('subscribe', 'test', { find: { id: 5 } }), context)); assert(rule.is_match(make_request('subscribe', 'test', { }), context)); }); for (const type of [ 'store', 'update', 'insert', 'upsert', 'replace', 'remove' ]) { it(`collection ${type}`, () => { const rule = new Rule('foo', { template: `collection("test").${type}(any())` }); assert(rule.is_valid()); assert(!rule.is_match(make_request(type, 'test', { }), context)); assert(!rule.is_match(make_request(type, 'test', { data: { } }), context)); assert(!rule.is_match(make_request(type, 'test', { data: [ ] }), context)); assert(!rule.is_match(make_request(type, 'fake', { data: [ { } ] }), context)); assert(!rule.is_match(make_request(type, 'test', { data: [ { } ], fake: 6 }), context)); assert(rule.is_match(make_request(type, 'test', { data: [ { } ] }), context)); }); it(`collection ${type} batch`, () => { const rule = new Rule('foo', { template: `collection("test").${type}(anyArray(any()))` }); assert(rule.is_valid()); assert(!rule.is_match(make_request(type, 'test', { }), context)); assert(!rule.is_match(make_request(type, 'test', { data: { } }), context)); assert(!rule.is_match(make_request(type, 'test', { data: [ { } ], fake: 6 }), context)); assert(!rule.is_match(make_request(type, 'fake', { data: [ { } ] }), context)); assert(rule.is_match(make_request(type, 'test', { data: [ ] }), context)); assert(rule.is_match(make_request(type, 'test', { data: [ { } ] }), context)); assert(rule.is_match(make_request(type, 'test', { data: [ { }, { bar: 'baz' } ] }), context)); }); } it('any write', () => { const rule = new Rule('foo', { template: 'collection("test").anyWrite()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('fake', 'test', { }), context)); assert(!rule.is_match(make_request('query', 'test', { }), context)); assert(!rule.is_match(make_request('store', null, { }), context)); for (const type of [ 'store', 'update', 'insert', 'upsert', 'replace', 'remove' ]) { assert(!rule.is_match(make_request(type, 'fake', { }), context)); assert(rule.is_match(make_request(type, 'test', { data: [ ] }), context)); assert(rule.is_match(make_request(type, 'test', { data: [ { } ] }), context)); assert(rule.is_match(make_request(type, 'test', { data: [ ], bar: 'baz' }), context)); } }); it('userId in find', () => { const rule = new Rule('foo', { template: 'collection("test").find({ owner: userId() }).fetch()' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'test', { find: { } }), context)); assert(!rule.is_match(make_request('query', 'test', { find: true }), context)); assert(!rule.is_match(make_request('query', 'test', { find: [ ] }), context)); assert(!rule.is_match(make_request('query', 'test', { find: { bar: 'baz' } }), context)); assert(!rule.is_match(make_request('query', 'test', { find: { owner: (context.id + 1) } }), context)); assert(!rule.is_match(make_request('query', 'test', { find: { owner: context.id, bar: 'baz' } }), context)); assert(rule.is_match(make_request('query', 'test', { find: { owner: context.id } }), context)); }); it('adds readAny() implicitly', () => { { const rule = new Rule('foo', { template: 'collection("test")' }); assert(rule.is_valid()); assert(rule.is_match(make_request('query', 'test', { find: { } }), context)); assert(rule.is_match(make_request('query', 'test', { find: { bar: 'baz' } }), context)); } { const rule = new Rule('foo', { template: 'collection("test").find({bar: any()})' }); assert(rule.is_valid()); assert(!rule.is_match(make_request('query', 'test', { find: { } }), context)); assert(rule.is_match(make_request('query', 'test', { find: { bar: 'baz' } }), context)); } }); it('error on incomplete template', () => { assert.throws(() => new Rule('foo', { template: '({ })' }), /Incomplete template/); assert.throws(() => new Rule('foo', { template: '[ ]' }), /Invalid template/); assert.throws(() => new Rule('foo', { template: '5' }), /Invalid template/); assert.throws(() => new Rule('foo', { template: 'null' }), /Invalid template/); }); }); describe('Validator', () => { it('unparseable', () => { assert.throws(() => new Validator('() => ;'), /Unexpected token/); }); it('broken', () => { const validator = new Validator('() => foo'); assert.throws(() => validator.is_valid(), /Validation error/); }); it('permitted', () => { const validator = new Validator(permitted_validator); assert(validator.is_valid({ id: 3 })); assert(validator.is_valid({ id: 3 }, { id: 0 })); assert(validator.is_valid({ id: 3 }, { id: 0 }, { id: 1 })); }); it('user permitted', () => { const validator = new Validator(user_permitted_validator); assert(validator.is_valid({ id: 3 }, { id: 3 })); assert(validator.is_valid({ id: 3 }, { id: 13 })); assert(!validator.is_valid({ id: 3 }, { id: 4 })); }); it('forbidden', () => { const validator = new Validator(forbidden_validator); assert(!validator.is_valid({ id: 3 })); assert(!validator.is_valid({ id: 3 }, { id: 3 })); assert(!validator.is_valid({ id: 3 }, { id: 0 }, { id: 1 })); }); }); describe('Validation', () => { const metadata = { collection: () => ({ table: r.table(collection), get_matching_index: () => ({ name: 'id', fields: [ 'id' ] }), }), connection: () => utils.rdb_conn(), }; const table_data = [ ]; for (let i = 0; i < 10; ++i) { table_data.push({ id: i }); } beforeEach('Clear test table', () => r.table(collection).delete().run(utils.rdb_conn())); beforeEach('Populate test table', () => r.table(collection).insert(table_data).run(utils.rdb_conn())); const make_run = (run_fn) => (options, validator, limit) => new Promise((resolve, reject) => { let cancel_fn; const request = { options }; const results = [ ]; const ruleset = new Ruleset(); ruleset.update([ new Rule('test', { template: 'any()', validator }) ]); options.collection = collection; const add_response = (res) => { res.data.forEach((item) => results.push(item)); if (limit && results.length >= limit) { cancel_fn(); resolve(results); } }; cancel_fn = run_fn( request, { id: 3 }, ruleset, metadata, add_response, (res_or_error) => { if (res_or_error instanceof Error) { res_or_error.results = results; reject(res_or_error); } else { if (res_or_error) { add_response(res_or_error); } resolve(results); } }); }); describe('query', () => { const run = make_run(query.run); it('permitted', () => run({ order: [ [ 'id' ], 'ascending' ] }, permitted_validator).then((res) => { assert.deepStrictEqual(res, table_data); })); it('half-permitted', () => run({ order: [ [ 'id' ], 'ascending' ], above: [ { id: 3 }, 'closed' ] }, user_permitted_validator).then(() => { assert(false, 'Read should not have been permitted.'); }).catch((err) => { assert.strictEqual(err.message, 'Operation not permitted.'); // Check that we got the permitted row or nothing (race condition) if (err.results.length !== 0) { assert.deepStrictEqual(err.results, [ { id: 3 } ]); } })); it('forbidden', () => run({ }, forbidden_validator).then(() => { assert(false, 'Read should not have been permitted.'); }).catch((err) => { assert.strictEqual(err.message, 'Operation not permitted.'); assert.strictEqual(err.results.length, 0); })); }); describe('subscribe', () => { const run = make_run(subscribe.run); it('permitted with subsequent permitted change', () => { // TODO: can't use run, need to issue a write during the subscription }); it('permitted with subsequent forbidden change', () => { // TODO: can't use run, need to issue a write during the subscription }); it('half-permitted', () => run({ order: [ [ 'id' ], 'ascending' ], above: [ { id: 3 }, 'closed' ] }, user_permitted_validator).then(() => { assert(false, 'Read should not have been permitted.'); }).catch((err) => { assert.strictEqual(err.message, 'Operation not permitted.'); // Check that we got the permitted row or nothing (race condition) if (err.results.length !== 0) { assert.deepStrictEqual(err.results, [ { id: 3 } ]); } })); it('forbidden', () => run({ }, forbidden_validator).then(() => { assert(false, 'Read should not have been permitted.'); }).catch((err) => { assert.strictEqual(err.message, 'Operation not permitted.'); assert.strictEqual(err.results.length, 0); })); }); describe('insert', () => { const run = make_run(insert.run); it('permitted', () => run({ data: [ { id: 11 } ] }, permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 11); return r.table(collection).get(11).eq(null) .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('permitted based on context', () => run({ data: [ { id: 13 } ] }, user_permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 13); return r.table(collection).get(13).eq(null) .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('forbidden', () => run({ data: [ { id: 11 } ] }, forbidden_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(11).ne(null) .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); it('forbidden based on context', () => run({ data: [ { id: 11 } ] }, user_permitted_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(11).ne(null) .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); }); describe('store', () => { const run = make_run(store.run); it('permitted', () => run({ data: [ { id: 11 } ] }, permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 11); return r.table(collection).get(11).eq(null) .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('permitted based on context', () => run({ data: [ { id: 13 } ] }, user_permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 13); return r.table(collection).get(13).eq(null) .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('forbidden', () => run({ data: [ { id: 11 } ] }, forbidden_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(11).ne(null) .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); it('forbidden based on context', () => run({ data: [ { id: 11 } ] }, user_permitted_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(11).ne(null) .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); }); describe('upsert', () => { const run = make_run(upsert.run); it('permitted', () => run({ data: [ { id: 11 } ] }, permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 11); return r.table(collection).get(11).eq(null) .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('permitted based on context', () => run({ data: [ { id: 13 } ] }, user_permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 13); return r.table(collection).get(13).eq(null) .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('forbidden', () => run({ data: [ { id: 11 } ] }, forbidden_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(11).ne(null) .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); it('forbidden based on context', () => run({ data: [ { id: 11 } ] }, user_permitted_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(11).ne(null) .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); }); describe('update', () => { const run = make_run(update.run); it('permitted', () => run({ data: [ { id: 1, value: 5 } ] }, permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 1); return r.table(collection).get(1).hasFields('value').not() .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('permitted based on context', () => run({ data: [ { id: 3, value: 5 } ] }, user_permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 3); return r.table(collection).get(3).hasFields('value').not() .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('forbidden', () => run({ data: [ { id: 1, value: 5 } ] }, forbidden_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(1).hasFields('value') .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); it('forbidden based on context', () => run({ data: [ { id: 1, value: 5 } ] }, user_permitted_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(1).hasFields('value') .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); }); describe('replace', () => { const run = make_run(replace.run); it('permitted', () => run({ data: [ { id: 1, value: 5 } ] }, permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 1); return r.table(collection).get(1).hasFields('value').not() .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('permitted based on context', () => run({ data: [ { id: 3, value: 5 } ] }, user_permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 3); return r.table(collection).get(3).hasFields('value').not() .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('forbidden', () => run({ data: [ { id: 1, value: 5 } ] }, forbidden_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(1).hasFields('value') .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); it('forbidden based on context', () => run({ data: [ { id: 1, value: 5 } ] }, user_permitted_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(1).hasFields('value') .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); }); describe('remove', () => { const run = make_run(remove.run); it('permitted', () => run({ data: [ { id: 1 } ] }, permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 1); return r.table(collection).get(1).ne(null) .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('permitted based on context', () => run({ data: [ { id: 3 } ] }, user_permitted_validator).then((res) => { assert.strictEqual(res.length, 1); assert.strictEqual(res[0].id, 3); return r.table(collection).get(3).ne(null) .branch(r.error('write did not go through'), null).run(utils.rdb_conn()); })); it('forbidden', () => run({ data: [ { id: 1 } ] }, forbidden_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(1).eq(null) .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); it('forbidden based on context', () => run({ data: [ { id: 1 } ] }, user_permitted_validator).then((res) => { assert.deepStrictEqual(res, [ { error: 'Operation not permitted.' } ]); return r.table(collection).get(1).eq(null) .branch(r.error('write went through'), null).run(utils.rdb_conn()); })); }); }); }; const suite = (collection) => describe('Permissions', () => all_tests(collection)); module.exports = { suite }; ================================================ FILE: server/test/prereq_tests.js ================================================ 'use strict'; const utils = require('./utils'); const assert = require('assert'); const crypto = require('crypto'); const all_tests = (collection) => { beforeEach('clear collection', (done) => utils.clear_collection(collection, done)); beforeEach('authenticate', (done) => utils.horizon_admin_auth(done)); // Launch simultaneous queries that depend on a non-existent collection, then // verify that only one table exists for that collection. it('collection create race on read', /** @this mocha */ function(done) { const query_count = 5; const rand_collection = crypto.randomBytes(8).toString('hex'); let finished = 0; for (let i = 0; i < query_count; ++i) { utils.stream_test( { request_id: i, type: 'query', options: { collection: rand_collection } }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 0); if (++finished === query_count) { utils.table(rand_collection).count().run(utils.rdb_conn()) .then((count) => (assert.strictEqual(count, 0), done()), (error) => done(error)); } }); } }); // Same as the previous test, but it exists because the ReQL error message // is different for a read or a write when the table is unavailable. it('collection create race on write', /** @this mocha */ function(done) { const query_count = 5; const rand_collection = crypto.randomBytes(8).toString('hex'); let finished = 0; for (let i = 0; i < query_count; ++i) { utils.stream_test( { request_id: i, type: 'insert', options: { collection: rand_collection, data: [ { } ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 1); if (++finished === query_count) { utils.table(rand_collection).count().run(utils.rdb_conn()) .then((count) => (assert.strictEqual(count, query_count), done()), (error) => done(error)); } }); } }); // Launch two simultaneous queries that depend on a non-existent index, then // verify that only one such index exists with that name. it('index create race', (done) => { const query_count = 5; const field_name = crypto.randomBytes(8).toString('hex'); const conn = utils.rdb_conn(); utils.table(collection).indexStatus().count().run(conn).then((old_count) => { let finished = 0; for (let i = 0; i < query_count; ++i) { utils.stream_test( { request_id: i, type: 'query', options: { collection, order: [ [ field_name ], 'ascending' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 0); if (++finished === query_count) { utils.table(collection).indexStatus().count().run(conn).then((new_count) => { assert.strictEqual(old_count + 1, new_count); done(); }, (err2) => done(err2)); } }); } }); }); }; const suite = (collection) => describe('Prereqs', () => all_tests(collection)); module.exports = { suite }; ================================================ FILE: server/test/protocol_tests.js ================================================ 'use strict'; const utils = require('./utils'); const assert = require('assert'); const all_tests = (collection) => { beforeEach('Authenticate client', utils.horizon_admin_auth); it('unparseable', (done) => { const conn = utils.horizon_conn(); conn.removeAllListeners('error'); conn.send('foobar'); conn.once('close', (code, reason) => { assert.strictEqual(code, 1002); assert(/^Invalid JSON/.test(reason)); done(); }); }); it('no request_id', (done) => { const conn = utils.horizon_conn(); conn.removeAllListeners('error'); conn.send('{ }'); conn.once('close', (code, reason) => { assert.strictEqual(code, 1002); assert(/^Protocol error: ValidationError/.test(reason)); done(); }); }); it('no type', (done) => { utils.stream_test({ request_id: 0 }, (err, res) => { assert.deepStrictEqual(res, [ ]); utils.check_error(err, '"type" is required'); done(); }); }); it('no options', (done) => { utils.stream_test({ request_id: 1, type: 'fake' }, (err, res) => { assert.deepStrictEqual(res, [ ]); utils.check_error(err, '"options" is required'); done(); }); }); it('invalid endpoint', (done) => { utils.stream_test({ request_id: 2, type: 'fake', options: { } }, (err, res) => { assert.deepStrictEqual(res, [ ]); assert.strictEqual(err.message, '"fake" is not a registered request type.'); done(); }); }); // Make sure the server properly cleans up a client connection when it // disconnects. Open a changefeed, disconnect the client, then make sure the // changefeed would have gotten an event. // We don't check any results, we're just seeing if the server crashes. it('client disconnect during changefeed', (done) => { utils.horizon_conn().send(JSON.stringify( { request_id: 3, type: 'subscribe', options: { collection }, })); utils.add_horizon_listener(3, (msg) => { if (msg.error !== undefined) { throw new Error(msg.error); } else if (msg.state === 'synced') { utils.close_horizon_conn(); utils.table(collection).insert({}).run(utils.rdb_conn()) .then(() => done()); } }); }); // Make sure the server properly cleans up a client connection when it // disconnects. Close the connection immediately after sending the request. // We don't check any results, we're just seeing if the server crashes. it('client disconnect during query', (done) => { utils.horizon_conn().send(JSON.stringify( { request_id: 4, type: 'query', options: { collection, field_name: 'id', }, }), () => (utils.close_horizon_conn(), done())); }); }; const suite = (collection) => describe('Protocol', () => all_tests(collection)); module.exports = { suite }; ================================================ FILE: server/test/query_tests.js ================================================ 'use strict'; const utils = require('./utils'); const assert = require('assert'); // TODO: ensure each row is present in the results const all_tests = (collection) => { const num_rows = 10; before('Clear collection', (done) => utils.clear_collection(collection, done)); before('Populate collection', (done) => utils.populate_collection(collection, num_rows, done)); beforeEach('Authenticate client', utils.horizon_admin_auth); it('collection scan.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, num_rows); done(); }); }); it('collection scan order.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, order: [ [ 'id' ], 'ascending' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, num_rows); done(); }); }); it('collection scan limit.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, limit: 2, }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 2); done(); }); }); it('collection scan order limit.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, order: [ [ 'id' ], 'descending' ], limit: 4, }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 4); done(); }); }); it('collection scan above.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, above: [ { id: 5 }, 'closed' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 5); done(); }); }); it('collection scan below.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, below: [ { id: 5 }, 'closed' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 6); done(); }); }); it('collection scan above below.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, above: [ { id: 5 }, 'open' ], below: [ { id: 7 }, 'open' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 1); done(); }); }); it('find.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find: { id: 4 }, }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 1); done(); }); }); it('find missing.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find: { id: 14 }, }, }, (err, res) => { assert.ifError(err); assert.deepStrictEqual(res, [ ]); done(); }); }); it('find_all.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { id: 4 }, { id: 6 }, { id: 9 } ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 3); done(); }); }); it('find_all order.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { id: 1 } ], order: [ [ 'value' ], 'descending' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 1); done(); }); }); it('find_all limit.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { id: 4 }, { id: 8 }, { id: 2 }, { id: 1 } ], limit: 3, }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 3); done(); }); }); it('find_all order limit.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { id: 4 } ], order: [ [ 'value' ], 'descending' ], limit: 3, }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 1); done(); }); }); it('find_all above.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 1 } ], above: [ { id: 3 }, 'open' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 2); done(); }); }); it('find_all below.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 1 } ], below: [ { id: 5 }, 'open' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 1); done(); }); }); it('find_all above below.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 1 } ], above: [ { id: 1 }, 'closed' ], below: [ { id: 9 }, 'open' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 2); done(); }); }); it('find_all order above.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 1 } ], order: [ [ 'id' ], 'ascending' ], above: [ { id: 7 }, 'open' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 1); done(); }); }); it('find_all order below.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 0 } ], order: [ [ 'id' ], 'descending' ], below: [ { id: 8 }, 'open' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 2); done(); }); }); it('find_all order above below.', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 0 } ], order: [ [ 'id' ], 'descending' ], above: [ { id: 3 }, 'closed' ], below: [ { id: 9 }, 'closed' ], }, }, (err, res) => { assert.ifError(err); assert.strictEqual(res.length, 2); done(); }); }); // These tests are impossible to represent in the schema (as far as I can tell), // so the test for this functionality must be at the integration level. it('find_all "above" field not in "order".', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 0 } ], order: [ [ 'value', 'a' ], 'descending' ], above: [ { b: 4 }, 'closed' ], }, }, (err) => { utils.check_error(err, '"above" must be on the same field as the first in "order"'); done(); }); }); it('find_all "above" field not first in "order".', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 0 } ], order: [ [ 'value', 'a' ], 'descending' ], above: [ { a: 4 }, 'closed' ], }, }, (err) => { utils.check_error(err, '"above" must be on the same field as the first in "order"'); done(); }); }); it('find_all "below" field not in "order".', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 0 } ], order: [ [ 'value', 'a' ], 'descending' ], below: [ { b: 4 }, 'closed' ], }, }, (err) => { utils.check_error(err, '"below" must be on the same field as the first in "order"'); done(); }); }); it('find_all "below" field not first in "order".', (done) => { utils.stream_test( { request_id: 0, type: 'query', options: { collection, find_all: [ { value: 0 } ], order: [ [ 'value', 'a' ], 'descending' ], below: [ { a: 4 }, 'closed' ], }, }, (err) => { utils.check_error(err, '"below" must be on the same field as the first in "order"'); done(); }); }); }; const suite = (collection) => describe('Query', () => all_tests(collection)); module.exports = { suite }; ================================================ FILE: server/test/schema.js ================================================ 'use strict'; const horizon_protocol = require('../src/schema/horizon_protocol'); const utils = require('./utils'); const assert = require('assert'); describe('Schema', () => { const test_required_fields = (schema, valid, fields) => { fields.forEach((f) => { const request = Object.assign({}, valid); // Create a shallow copy request[f] = undefined; const error = schema.validate(request).error; utils.check_error(error, `"${f}" is required`); }); }; const test_extra_field = (schema, valid) => { const request = Object.assign({}, valid); // Create a shallow copy request.fake_field = false; const error = schema.validate(request).error; utils.check_error(error, '"fake_field" is not allowed'); }; describe('Protocol', () => { describe('Request', () => { const valid = { request_id: 1, type: 'query', options: { }, }; it('valid', () => { const parsed = horizon_protocol.request.validate(valid); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, valid); }); it('required fields', () => { test_required_fields(horizon_protocol.request, valid, [ 'request_id', 'type', 'options' ]); }); it('wrong "request_id" type', () => { const request = Object.assign({}, valid); request.request_id = 'str'; const error = horizon_protocol.request.validate(request).error; utils.check_error(error, '"request_id" must be a number'); }); it('wrong "type" type', () => { const request = Object.assign({}, valid); request.type = 5; const error = horizon_protocol.request.validate(request).error; utils.check_error(error, '"type" must be a string'); }); it('wrong "options" type', () => { const request = Object.assign({}, valid); request.options = [ 5, 6 ]; const error = horizon_protocol.request.validate(request).error; utils.check_error(error, '"options" must be an object'); }); it('extra field', () => { test_extra_field(horizon_protocol.request, valid); }); }); describe('Write', () => { const write_without_id = { collection: 'horizon', data: [ { field: 4 } ], }; const write_with_id = { collection: 'horizon', data: [ { id: 5, field: 4 } ], }; // In order to reduce the number of tests, these were written assuming // that only two types of write schemas exist: id-required and id-optional. // If this ever changes, this test will fail and more tests may need to // be added. it('common write schemas', () => { // These schemas do not require an id in each "data" object assert.equal(horizon_protocol.insert, horizon_protocol.upsert); assert.equal(horizon_protocol.insert, horizon_protocol.store); // These schemas require an id in each "data" object assert.equal(horizon_protocol.replace, horizon_protocol.update); assert.equal(horizon_protocol.replace, horizon_protocol.remove); }); describe('Insert', () => { it('with id', () => { const error = horizon_protocol.insert.validate(write_with_id).error; assert.ifError(error); }); it('without id', () => { const error = horizon_protocol.insert.validate(write_without_id).error; assert.ifError(error); }); it('required fields', () => { test_required_fields(horizon_protocol.insert, write_with_id, [ 'collection', 'data' ]); }); it('extra field', () => { test_extra_field(horizon_protocol.insert, write_with_id); }); it('wrong "collection" type', () => { const request = Object.assign({}, write_with_id); request.collection = true; const error = horizon_protocol.insert.validate(request).error; utils.check_error(error, '"collection" must be a string'); }); it('wrong "collection" value', () => { const request = Object.assign({}, write_with_id); request.collection = '*.*'; const error = horizon_protocol.insert.validate(request).error; utils.check_error(error, '"collection" must only contain alpha-numeric and underscore characters'); }); it('wrong "data" type', () => { const request = Object.assign({}, write_with_id); request.data = 'abc'; const error = horizon_protocol.insert.validate(request).error; utils.check_error(error, '"data" must be an array'); }); it('wrong "data" member type', () => { const request = Object.assign({}, write_with_id); request.data = [ 7 ]; const error = horizon_protocol.insert.validate(request).error; utils.check_error(error, '"0" must be an object'); }); it('empty "data" array', () => { const request = Object.assign({}, write_with_id); request.data = [ ]; const error = horizon_protocol.insert.validate(request).error; utils.check_error(error, '"data" must contain at least 1 items'); }); }); describe('Replace', () => { it('with id', () => { const error = horizon_protocol.replace.validate(write_with_id).error; assert.ifError(error); }); it('without id', () => { const error = horizon_protocol.replace.validate(write_without_id).error; utils.check_error(error, '"id" is required'); }); it('required fields', () => { test_required_fields(horizon_protocol.replace, write_with_id, [ 'collection', 'data' ]); }); it('extra field', () => { test_extra_field(horizon_protocol.replace, write_with_id); }); it('wrong "collection" type', () => { const request = Object.assign({}, write_with_id); request.collection = true; const error = horizon_protocol.replace.validate(request).error; utils.check_error(error, '"collection" must be a string'); }); it('wrong "collection" value', () => { const request = Object.assign({}, write_with_id); request.collection = '*.*'; const error = horizon_protocol.insert.validate(request).error; utils.check_error(error, '"collection" must only contain alpha-numeric and underscore characters'); }); it('wrong "data" type', () => { const request = Object.assign({}, write_with_id); request.data = 'abc'; const error = horizon_protocol.replace.validate(request).error; utils.check_error(error, '"data" must be an array'); }); it('wrong "data" member type', () => { const request = Object.assign({}, write_with_id); request.data = [ 7 ]; const error = horizon_protocol.replace.validate(request).error; utils.check_error(error, '"0" must be an object'); }); it('empty "data" array', () => { const request = Object.assign({}, write_with_id); request.data = [ ]; const error = horizon_protocol.replace.validate(request).error; utils.check_error(error, '"data" must contain at least 1 items'); }); }); }); describe('Read', () => { // The 'query' and 'subscribe' requests use the same schema it('common read schemas', () => { assert.equal(horizon_protocol.query, horizon_protocol.subscribe); }); describe('no selection', () => { const valid = { collection: 'horizon', }; it('valid', () => { const parsed = horizon_protocol.query.validate(valid); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, valid); }); it('required fields', () => { test_required_fields(horizon_protocol.query, valid, [ 'collection' ]); }); it('extra field', () => { test_extra_field(horizon_protocol.query, valid); }); it('order', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'ascending' ]; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('above', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'ascending' ]; request.above = [ { id: 10 }, 'open' ]; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('below', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'ascending' ]; request.below = [ { id: 5 }, 'open' ]; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('limit', () => { const request = Object.assign({}, valid); request.limit = 2; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('above and below and limit', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'ascending' ]; request.below = [ { id: 0 }, 'closed' ]; request.below = [ { id: 5 }, 'closed' ]; request.limit = 4; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('wrong "collection" type', () => { const request = Object.assign({}, valid); request.collection = null; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"collection" must be a string'); }); it('wrong "collection" value', () => { const request = Object.assign({}, valid); request.collection = '*.*'; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"collection" must only contain alpha-numeric and underscore characters'); }); it('wrong "order" type', () => { const request = Object.assign({}, valid); request.order = true; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"order" must be an array'); }); it('wrong "order" value', () => { const request = Object.assign({}, valid); { request.order = [ ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"order" does not contain [fields, direction]'); } { request.order = [ [ 'id' ] ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"order" does not contain [direction]'); } { request.order = [ { }, 'ascending' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"fields" must be an array'); } { request.order = [ [ ], 'descending' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"fields" must contain at least 1 item'); } { request.order = [ [ 'field' ], 'baleeted' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"direction" must be one of [ascending, descending]'); } }); it('"above" without "order"', () => { const request = Object.assign({}, valid); request.above = [ { id: 5 }, 'open' ]; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('wrong "above" type', () => { const request = Object.assign({}, valid); request.above = true; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"above" must be an array'); }); it('wrong "above" value', () => { const request = Object.assign({}, valid); { request.above = [ ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"above" does not contain [value, bound_type]'); } { request.above = [ 1, 'closed' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"value" must be an object'); } { request.above = [ { }, 'open' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"value" must have 1 child'); } { request.above = [ { id: 4 }, 5 ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"bound_type" must be a string'); } { request.above = [ { id: 3 }, 'ajar' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"bound_type" must be one of [open, closed]'); } }); it('"below" without "order"', () => { const request = Object.assign({}, valid); request.below = [ { id: 1 }, 'open' ]; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('wrong "below" type', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'ascending' ]; request.below = true; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"below" must be an array'); }); it('wrong "below" value', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'ascending' ]; { request.below = [ ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"below" does not contain [value, bound_type]'); } { request.below = [ 1, 'closed' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"value" must be an object'); } { request.below = [ { }, 'open' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"value" must have 1 child'); } { request.below = [ { id: 4 }, 5 ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"bound_type" must be a string'); } { request.below = [ { id: 3 }, 'ajar' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"bound_type" must be one of [open, closed]'); } }); it('wrong "limit" type', () => { const request = Object.assign({}, valid); request.limit = true; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"limit" must be a number'); }); it('wrong "limit" value', () => { const request = Object.assign({}, valid); { request.limit = -1; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"limit" must be greater than -1'); } { request.limit = 1.5; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"limit" must be an integer'); } }); }); describe('find', () => { const valid = { collection: 'horizon', find: { score: 4 }, }; it('valid', () => { const parsed = horizon_protocol.query.validate(valid); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, valid); }); it('order', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'ascending' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"order" is not allowed'); }); it('above', () => { const request = Object.assign({}, valid); request.above = [ { id: 3 }, 'open' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"above" is not allowed'); }); it('below', () => { const request = Object.assign({}, valid); request.below = [ { id: 4 }, 'closed' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"below" is not allowed'); }); it('limit', () => { const request = Object.assign({}, valid); request.limit = 4; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"limit" is not allowed'); }); it('wrong "find" type', () => { const request = Object.assign({}, valid); request.find = 'score'; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"find" must be an object'); }); }); describe('find_all multiple', () => { const valid = { collection: 'horizon', find_all: [ { score: 2 }, { score: 5, id: 0 } ], }; it('valid', () => { const parsed = horizon_protocol.query.validate(valid); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, valid); }); it('order', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'descending' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"order" is not allowed'); }); it('limit', () => { const request = Object.assign({}, valid); request.limit = 2; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('above', () => { const request = Object.assign({}, valid); { request.above = [ { id: 3 }, 'closed' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"above" is not allowed'); } { request.order = [ [ 'id' ], 'ascending' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"order" is not allowed'); } }); it('below', () => { const request = Object.assign({}, valid); { request.below = [ { id: 9 }, 'closed' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"below" is not allowed'); } { request.order = [ [ 'id' ], 'descending' ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"order" is not allowed'); } }); }); describe('find_all one', () => { const valid = { collection: 'horizon', find_all: [ { score: 8, id: 5 } ], }; it('valid', () => { const parsed = horizon_protocol.query.validate(valid); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, valid); }); it('order', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'descending' ]; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('limit', () => { const request = Object.assign({}, valid); request.limit = 2; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('above', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'ascending' ]; request.above = [ { id: 3 }, 'closed' ]; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('below', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'descending' ]; request.below = [ { id: 9 }, 'closed' ]; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('above and below and limit', () => { const request = Object.assign({}, valid); request.order = [ [ 'id' ], 'descending' ]; request.above = [ { id: 'foo' }, 'open' ]; request.below = [ { id: 'bar' }, 'closed' ]; request.limit = 59; const parsed = horizon_protocol.query.validate(request); assert.ifError(parsed.error); assert.deepStrictEqual(parsed.value, request); }); it('wrong "find_all" type', () => { const request = Object.assign({}, valid); request.find_all = null; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"find_all" must be an array'); }); it('wrong "find_all" value', () => { const request = Object.assign({}, valid); { request.find_all = [ ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"find_all" must contain at least 1 items'); } { request.find_all = [ { } ]; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"item" must have at least 1 child'); } }); it('with "find"', () => { const request = Object.assign({}, valid); request.find = { id: 7 }; const error = horizon_protocol.query.validate(request).error; utils.check_error(error, '"find" is not allowed'); // TODO: better message? }); }); }); }); }); ================================================ FILE: server/test/subscribe_tests.js ================================================ 'use strict'; const utils = require('./utils'); const all_tests = (collection) => { const num_rows = 10; before('Clear collection', (done) => utils.clear_collection(collection, done)); before('Populate collection', (done) => utils.populate_collection(collection, num_rows, done)); beforeEach('Authenticate client', utils.horizon_admin_auth); }; const suite = (collection) => describe('Subscribe', () => all_tests(collection)); module.exports = { suite }; ================================================ FILE: server/test/test.js ================================================ 'use strict'; const logger = require('../src/logger'); const utils = require('./utils'); const all_suites = [ 'http_tests', 'prereq_tests', 'protocol_tests', 'query_tests', 'subscribe_tests', 'write_tests', 'permissions' ]; const collection = 'test'; before('Start RethinkDB Server', () => utils.start_rethinkdb()); after('Stop RethinkDB Server', () => utils.stop_rethinkdb()); beforeEach( /** @this mocha */ function() { logger.info(`Start test '${this.currentTest.title}'`); }); afterEach( /** @this mocha */ function() { logger.info(`End test '${this.currentTest.title}'`); }); describe('Horizon Server', /** @this mocha */ function() { before('Start Horizon Server', utils.start_horizon_server); after('Close Horizon Server', utils.close_horizon_server); before(`Creating general-purpose collection: '${collection}'`, (done) => utils.create_collection(collection, done)); beforeEach('Connect Horizon Client', utils.open_horizon_conn); afterEach('Close Horizon Client', utils.close_horizon_conn); all_suites.forEach((s) => require(`./${s}`).suite(collection)); }); ================================================ FILE: server/test/utils.js ================================================ 'use strict'; const horizon = require('../src/server'); const logger = require('../src/logger'); const rm_sync_recursive = require('../../cli/src/utils/rm_sync_recursive'); const start_rdb_server = require('../../cli/src/utils/start_rdb_server'); const each_line_in_pipe = require('../../cli/src/utils/each_line_in_pipe'); const assert = require('assert'); const http = require('http'); const r = require('rethinkdb'); const websocket = require('ws'); const project_name = 'unittest'; const data_dir = './rethinkdb_data_test'; const log_file = `./horizon_test_${process.pid}.log`; logger.level = 'debug'; logger.add(logger.transports.File, { filename: log_file }); logger.remove(logger.transports.Console); // Variables used by most tests let rdb_server, rdb_http_port, rdb_port, rdb_conn, horizon_server, horizon_port, horizon_conn, horizon_listeners; let horizon_authenticated = false; const start_rethinkdb = () => { logger.info('removing dir'); rm_sync_recursive(data_dir); logger.info('creating server'); return start_rdb_server({ dataDir: data_dir }).then((server) => { rdb_server = server; rdb_port = server.driver_port; rdb_http_port = server.http_port; logger.info('server created, connecting'); return r.connect({ db: project_name, port: rdb_port }); }).then((conn) => { logger.info('connected'); rdb_conn = conn; return r.dbCreate(project_name).run(conn); }).then((res) => { assert.strictEqual(res.dbs_created, 1); }); }; const stop_rethinkdb = () => rdb_server.close(); // Used to prefix reql queries with the underlying table of a given collection const table = (collection) => r.table( r.db(project_name) .table('hz_collections') .get(collection) .do((row) => r.branch(row.eq(null), r.error('Collection does not exist.'), row('id')))); const make_admin_token = () => { const jwt = horizon_server && horizon_server._auth && horizon_server._auth._jwt; assert(jwt); return jwt.sign({ id: 'admin', provider: null }).token; }; // Creates a collection, no-op if it already exists, uses horizon server prereqs const create_collection = (collection, done) => { assert.notStrictEqual(horizon_server, undefined); assert.notStrictEqual(horizon_port, undefined); const conn = new websocket(`ws://localhost:${horizon_port}/horizon`, horizon.protocol, { rejectUnauthorized: false }) .once('error', (err) => assert.ifError(err)) .on('open', () => { conn.send(JSON.stringify({ request_id: 123, method: 'token', token: make_admin_token() })); conn.once('message', (data) => { const res = JSON.parse(data); assert.strictEqual(res.request_id, 123); assert.strictEqual(typeof res.token, 'string'); assert.strictEqual(res.id, 'admin'); assert.strictEqual(res.provider, null); // This query should auto-create the collection if it's missing conn.send(JSON.stringify({ request_id: 0, type: 'query', options: { collection, limit: 0 }, })); conn.once('message', () => { conn.close(); done(); }); }); }); }; // Removes all data from a collection - does not remove indexes const clear_collection = (collection, done) => { assert.notStrictEqual(rdb_conn, undefined); table(collection).delete().run(rdb_conn).then(() => done()); }; // Populates a collection with the given rows // If `rows` is a number, fill in data using all keys in [0, rows) const populate_collection = (collection, rows, done) => { assert.notStrictEqual(rdb_conn, undefined); if (rows.constructor.name !== 'Array') { table(collection).insert( r.range(rows).map( (i) => ({ id: i, value: i.mod(4) }) )).run(rdb_conn).then(() => done()); } else { table(collection).insert(rows).run(rdb_conn).then(() => done()); } }; const start_horizon_server = (done) => { logger.info('creating http server'); assert.strictEqual(horizon_server, undefined); const http_server = new http.Server(); http_server.listen(0, () => { logger.info('creating horizon server'); horizon_port = http_server.address().port; horizon_server = new horizon.Server(http_server, { project_name, rdb_port, auto_create_collection: true, auto_create_index: true, permissions: true, auth: { token_secret: 'hunter2', allow_unauthenticated: true, }, }); horizon_server.ready().catch((err) => logger.info(`horizon server error: ${err}`)); horizon_server.ready().then(() => logger.info('horizon server ready')); horizon_server.ready().then(() => done()); }); http_server.on('error', (err) => done(err)); }; const close_horizon_server = () => { if (horizon_server !== undefined) { horizon_server.close(); } horizon_server = undefined; }; const add_horizon_listener = (request_id, cb) => { assert(horizon_authenticated, 'horizon_conn was not authenticated before making requests'); assert.notStrictEqual(request_id, undefined); assert.notStrictEqual(horizon_listeners, undefined); assert.strictEqual(horizon_listeners.get(request_id), undefined); horizon_listeners.set(request_id, cb); }; const remove_horizon_listener = (request_id) => { assert.notStrictEqual(request_id, undefined); assert.notStrictEqual(horizon_listeners, undefined); horizon_listeners.delete(request_id); }; const dispatch_message = (raw) => { const msg = JSON.parse(raw); assert.notStrictEqual(msg.request_id, undefined); assert.notStrictEqual(horizon_listeners, undefined); if (msg.request_id !== null) { const listener = horizon_listeners.get(msg.request_id); assert.notStrictEqual(listener, undefined); listener(msg); } }; const open_horizon_conn = (done) => { logger.info('opening horizon conn'); assert.notStrictEqual(horizon_server, undefined); assert.strictEqual(horizon_conn, undefined); horizon_authenticated = false; horizon_listeners = new Map(); horizon_conn = new websocket(`ws://localhost:${horizon_port}/horizon`, horizon.protocol, { rejectUnauthorized: false }) .once('error', (err) => assert.ifError(err)) .on('open', () => done()); }; const close_horizon_conn = () => { logger.info('closing horizon conn'); if (horizon_conn) { horizon_conn.close(); } horizon_conn = undefined; horizon_listeners = undefined; horizon_authenticated = false; }; const horizon_auth = (req, cb) => { assert(horizon_conn && horizon_conn.readyState === websocket.OPEN); horizon_conn.send(JSON.stringify(req)); horizon_conn.once('message', (auth_msg) => { horizon_authenticated = true; const res = JSON.parse(auth_msg); horizon_conn.on('message', (msg) => dispatch_message(msg)); cb(res); }); }; // Create a token for the admin user and use that to authenticate const horizon_admin_auth = (done) => { horizon_auth({ request_id: -1, method: 'token', token: make_admin_token() }, (res) => { assert.strictEqual(res.request_id, -1); assert.strictEqual(typeof res.token, 'string'); assert.strictEqual(res.id, 'admin'); assert.strictEqual(res.provider, null); done(); }); }; const horizon_default_auth = (done) => { horizon_auth({ request_id: -1, method: 'unauthenticated' }, (res) => { assert.strictEqual(res.request_id, -1); assert.strictEqual(typeof res.token, 'string'); assert.strictEqual(res.id, null); assert.strictEqual(res.provider, 'unauthenticated'); done(); }); }; // `stream_test` will send a request (containing a request_id), and call the // callback with (err, res), where `err` is the error string if an error // occurred, or `null` otherwise. `res` will be an array, being the concatenation // of all `data` items returned by the server for the given request_id. // TODO: this doesn't allow for dealing with multiple states (like 'synced'). const stream_test = (req, cb) => { assert(horizon_conn && horizon_conn.readyState === websocket.OPEN); const results = []; add_horizon_listener(req.request_id, (msg) => { if (msg.data !== undefined) { results.push.apply(results, msg.data); } if (msg.error !== undefined) { remove_horizon_listener(req.request_id); cb(new Error(msg.error), results); } else if (msg.state === 'complete') { remove_horizon_listener(req.request_id); cb(null, results); } }); horizon_conn.send(JSON.stringify(req)); }; const check_error = (err, msg) => { assert.notStrictEqual(err, null, 'Should have gotten an error.'); assert(err.message.indexOf(msg) !== -1, err.message); }; const set_group = (group, done) => { assert(horizon_server && rdb_conn); r.db(project_name) .table('hz_groups') .get(group.id) .replace(group) .run(rdb_conn) .then((res, err) => { assert.ifError(err); assert(res && res.errors === 0); done(); }); }; module.exports = { rdb_conn: () => rdb_conn, rdb_http_port: () => rdb_http_port, rdb_port: () => rdb_port, horizon_conn: () => horizon_conn, horizon_port: () => horizon_port, horizon_listeners: () => horizon_listeners, start_rethinkdb, stop_rethinkdb, create_collection, populate_collection, clear_collection, start_horizon_server, close_horizon_server, open_horizon_conn, close_horizon_conn, horizon_auth, horizon_admin_auth, horizon_default_auth, add_horizon_listener, remove_horizon_listener, set_group, stream_test, check_error, each_line_in_pipe, table, }; ================================================ FILE: server/test/write_tests.js ================================================ 'use strict'; const utils = require('./utils'); const horizon_writes = require('../src/endpoint/writes'); const assert = require('assert'); const crypto = require('crypto'); const hz_v = horizon_writes.version_field; const invalidated_msg = horizon_writes.invalidated_msg; // Before each test, ids [0, 4) will be present in the collection const original_data = [ { id: 0, old_field: [ ], [hz_v]: 0 }, { id: 1, old_field: [ ], [hz_v]: 0 }, { id: 2, old_field: [ ], [hz_v]: 0 }, { id: 3, old_field: [ ], [hz_v]: 0 }, ]; const new_id = [ 4 ]; const conflict_id = [ 3 ]; const new_ids = [ 4, 5, 6 ]; const conflict_ids = [ 2, 3, 4 ]; const without_version = (item) => { const res = Object.assign({ }, item); delete res[hz_v]; return res; }; const compare_write_response = (actual, expected) => { assert.deepStrictEqual(actual.map(without_version), expected); }; const check_collection_data = (actual, expected) => { // TODO: make sure that versions increment properly assert.deepStrictEqual(actual.map(without_version), expected.map(without_version)); }; // TODO: verify through reql that rows have been inserted/removed const all_tests = (collection) => { const new_row_from_id = (id) => ({ id, new_field: 'a' }); const merged_row_from_id = (id) => { if (id >= 4) { return new_row_from_id(id); } return { id, new_field: 'a', old_field: [ ] }; }; const make_request = (type, data, options) => ({ request_id: crypto.randomBytes(4).readUInt32BE(), type, options: Object.assign({}, options || {}, { collection, data }), }); const check_collection = (expected, done) => { utils.table(collection).orderBy({ index: 'id' }).coerceTo('array') .run(utils.rdb_conn()).then((res) => { check_collection_data(res, expected); done(); }).catch((err) => done(err)); }; const combine_sort_data = (old_data, new_data, on_new, on_conflict) => { const map = new Map(); old_data.forEach((row) => map.set(row.id, row)); new_data.forEach((row) => { if (map.has(row.id)) { on_conflict(row, map); } else { on_new(row, map); } }); return Array.from(map.values()).sort((a, b) => a.id - b.id); }; const union_sort_data = (old_data, new_data) => combine_sort_data(old_data, new_data, (row, map) => map.set(row.id, row), (row, map) => map.set(row.id, row)); const replace_sort_data = (old_data, new_data) => combine_sort_data(old_data, new_data, () => null, (row, map) => map.set(row.id, row)); beforeEach('Clear collection', (done) => utils.clear_collection(collection, done)); describe('Basic writes', () => { beforeEach('Authenticate', (done) => utils.horizon_admin_auth(done)); beforeEach('Populate collection', (done) => utils.populate_collection(collection, original_data, done)); const request_from_ids = (type, ids) => make_request(type, ids.map(new_row_from_id)); describe('Store', () => { const test_case = (ids, done) => { utils.stream_test(request_from_ids('store', ids), (err, res) => { const expected = ids.map((id) => ({ id })); assert.ifError(err); compare_write_response(res, expected); const new_data = ids.map(new_row_from_id); check_collection(union_sort_data(original_data, new_data), done); }); }; it('new', (done) => test_case(new_id, done)); it('conflict', (done) => test_case(conflict_id, done)); it('batch new', (done) => test_case(new_ids, done)); it('batch conflict', (done) => test_case(conflict_ids, done)); }); describe('Replace', () => { const test_case = (ids, done) => { utils.stream_test(request_from_ids('replace', ids), (err, res) => { const expected = ids.map((id) => (id < original_data.length ? { id } : { error: 'The document was missing.' }) ); assert.ifError(err); compare_write_response(res, expected); const new_data = ids.map(new_row_from_id); check_collection(replace_sort_data(original_data, new_data), done); }); }; it('new', (done) => test_case(new_id, done)); it('conflict', (done) => test_case(conflict_id, done)); it('batch new', (done) => test_case(new_ids, done)); it('batch conflict', (done) => test_case(conflict_ids, done)); }); describe('Upsert', () => { const test_case = (ids, done) => { utils.stream_test(request_from_ids('upsert', ids), (err, res) => { const expected = ids.map((id) => ({ id })); assert.ifError(err); compare_write_response(res, expected); const new_data = ids.map(merged_row_from_id); check_collection(union_sort_data(original_data, new_data), done); }); }; it('new', (done) => test_case(new_id, done)); it('conflict', (done) => test_case(conflict_id, done)); it('batch new', (done) => test_case(new_ids, done)); it('batch conflict', (done) => test_case(conflict_ids, done)); }); describe('Update', () => { const test_case = (ids, done) => { utils.stream_test(request_from_ids('update', ids), (err, res) => { const expected = ids.map((id) => (id < original_data.length ? { id } : { error: 'The document was missing.' }) ); assert.ifError(err); compare_write_response(res, expected); const new_data = ids.map(merged_row_from_id); check_collection(replace_sort_data(original_data, new_data), done); }); }; it('new', (done) => test_case(new_id, done)); it('conflict', (done) => test_case(conflict_id, done)); it('batch new', (done) => test_case(new_ids, done)); it('batch conflict', (done) => test_case(conflict_ids, done)); }); describe('Insert', () => { const add_sort_data = (old_data, new_data) => combine_sort_data(old_data, new_data, (row, map) => map.set(row.id, row), () => null); const test_case = (ids, done) => { utils.stream_test(request_from_ids('insert', ids), (err, res) => { const expected = ids.map((id) => (id >= original_data.length ? { id } : { error: 'The document already exists.' }) ); assert.ifError(err); compare_write_response(res, expected); const new_data = ids.map(new_row_from_id); check_collection(add_sort_data(original_data, new_data), done); }); }; it('new', (done) => test_case(new_id, done)); it('conflict', (done) => test_case(conflict_id, done)); it('batch new', (done) => test_case(new_ids, done)); it('batch conflict', (done) => test_case(conflict_ids, done)); }); describe('Remove', () => { // `old_data` and `new_data` may overlap, but each cannot contain duplicates const remove_sort_data = (old_data, new_data) => combine_sort_data(old_data, new_data, () => null, (row, map) => map.delete(row.id)); const test_case = (ids, done) => { utils.stream_test(request_from_ids('remove', ids), (err, res) => { const expected = ids.map((id) => ({ id })); assert.ifError(err); compare_write_response(res, expected); const deleted_data = ids.map(new_row_from_id); check_collection(remove_sort_data(original_data, deleted_data), done); }); }; it('new', (done) => test_case(new_id, done)); it('conflict', (done) => test_case(conflict_id, done)); it('batch new', (done) => test_case(new_ids, done)); it('batch conflict', (done) => test_case(conflict_ids, done)); }); }); describe('Versioned', () => { beforeEach('Authenticate', (done) => utils.horizon_admin_auth(done)); const test_data = [ { id: 'versioned', [hz_v]: 11, foo: 'bar' } ]; beforeEach('Populate collection', (done) => utils.populate_collection(collection, test_data, done)); describe('Store', () => { const request = (row) => make_request('store', [ row ]); it('correct version', (done) => { utils.stream_test(request({ id: 'versioned', value: 1, [hz_v]: 11 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versioned', [hz_v]: 12 } ]; assert.deepStrictEqual(res, expected); check_collection([ { id: 'versioned', [hz_v]: 12, value: 1 } ], done); }); }); it('incorrect version', (done) => { utils.stream_test(request({ id: 'versioned', value: 2, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); describe('Replace', () => { const request = (row) => make_request('replace', [ row ]); it('correct version', (done) => { utils.stream_test(request({ id: 'versioned', value: 1, [hz_v]: 11 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versioned', [hz_v]: 12 } ]; assert.deepStrictEqual(res, expected); check_collection([ { id: 'versioned', [hz_v]: 12, value: 1 } ], done); }); }); it('incorrect version', (done) => { utils.stream_test(request({ id: 'versioned', value: 2, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); describe('Upsert', () => { const request = (row) => make_request('upsert', [ row ]); it('correct version', (done) => { utils.stream_test(request({ id: 'versioned', value: 1, [hz_v]: 11 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versioned', [hz_v]: 12 } ]; assert.deepStrictEqual(res, expected); check_collection([ { id: 'versioned', [hz_v]: 12, value: 1, foo: 'bar' } ], done); }); }); it('incorrect version', (done) => { utils.stream_test(request({ id: 'versioned', value: 2, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); describe('Update', () => { const request = (row) => make_request('update', [ row ]); it('correct version', (done) => { utils.stream_test(request({ id: 'versioned', value: 1, [hz_v]: 11 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versioned', [hz_v]: 12 } ]; assert.deepStrictEqual(res, expected); check_collection([ { id: 'versioned', [hz_v]: 12, value: 1, foo: 'bar' } ], done); }); }); it('incorrect version', (done) => { utils.stream_test(request({ id: 'versioned', value: 2, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); describe('Remove', () => { const request = (row) => make_request('remove', [ row ]); it('correct version', (done) => { utils.stream_test(request({ id: 'versioned', value: 1, [hz_v]: 11 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versioned', [hz_v]: 11 } ]; assert.deepStrictEqual(res, expected); check_collection([ ], done); }); }); it('incorrect version', (done) => { utils.stream_test(request({ id: 'versioned', value: 2, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); }); describe('Versionless', () => { beforeEach('Authenticate', (done) => utils.horizon_admin_auth(done)); const test_data = [ { id: 'versionless', foo: 'bar' } ]; beforeEach('Populate collection', (done) => utils.populate_collection(collection, test_data, done)); describe('Store', () => { const request = (row) => make_request('store', [ row ]); it('unspecified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 3 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versionless', [hz_v]: 0 } ]; assert.deepStrictEqual(res, expected); check_collection([ { id: 'versionless', [hz_v]: 0, value: 3 } ], done); }); }); it('specified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 4, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); describe('Replace', () => { const request = (row) => make_request('replace', [ row ]); it('unspecified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 3 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versionless', [hz_v]: 0 } ]; assert.deepStrictEqual(res, expected); check_collection([ { id: 'versionless', [hz_v]: 0, value: 3 } ], done); }); }); it('specified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 4, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); describe('Upsert', () => { const request = (row) => make_request('upsert', [ row ]); it('unspecified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 3 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versionless', [hz_v]: 0 } ]; assert.deepStrictEqual(res, expected); check_collection([ { id: 'versionless', [hz_v]: 0, value: 3, foo: 'bar' } ], done); }); }); it('specified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 4, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); describe('Update', () => { const request = (row) => make_request('update', [ row ]); it('unspecified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 3 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versionless', [hz_v]: 0 } ]; assert.deepStrictEqual(res, expected); check_collection([ { id: 'versionless', [hz_v]: 0, value: 3, foo: 'bar' } ], done); }); }); it('specified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 4, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); describe('Remove', () => { const request = (row) => make_request('remove', [ row ]); it('unspecified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 3 }), (err, res) => { assert.ifError(err); const expected = [ { id: 'versionless' } ]; assert.deepStrictEqual(res, expected); check_collection([ ], done); }); }); it('specified version', (done) => { utils.stream_test(request({ id: 'versionless', value: 4, [hz_v]: 5 }), (err, res) => { assert.ifError(err); const expected = [ { error: invalidated_msg } ]; assert.deepStrictEqual(res, expected); check_collection(test_data, done); }); }); }); }); // To guarantee multiple retries of a write, we combine a batch of writes // for the same row (unspecified versions) with a validator. This way, only one // write will make it through each loop, although it is undefined in which order // the writes occur. describe('Retry', () => { beforeEach('Authenticate', (done) => utils.horizon_default_auth(done)); // Set a catch-all rule for the 'default' group so we can have a validator before('Set rules', (done) => utils.set_group({ id: 'default', rules: { dummy: { template: 'any()', validator: '() => true', }, }, }, done)); const writes = [ { id: 0, a: 1 }, { id: 0, b: 2 }, { id: 0, c: 3 }, ]; const by_version = (a, b) => a[hz_v] - b[hz_v]; const check_and_get_latest_write = (res) => { const latest_index = res.findIndex((x) => x[hz_v] === 2); assert(latest_index !== -1); res.sort(by_version); assert.deepStrictEqual(res, [ { id: 0, [hz_v]: 0 }, { id: 0, [hz_v]: 1 }, { id: 0, [hz_v]: 2 } ]); return writes[latest_index]; }; // For some tests, we expect exactly one write to succeed and the others // to fail. Which write succeeds is not guaranteed to be deterministic, // so we return the successful write data. const check_one_successful_write = (res, error) => { const success_index = res.findIndex((x) => x.error === undefined); assert(success_index !== -1); for (let i = 0; i < res.length; ++i) { if (i === success_index) { assert.deepStrictEqual(res[i], { id: 0, [hz_v]: 0 }); } else { assert.deepStrictEqual(res[i], { error }); } } return writes[success_index]; }; describe('Existing Row', () => { const test_data = [ { id: 0, value: 0 } ]; beforeEach('Populate collection', (done) => utils.populate_collection(collection, test_data, done)); it('Store', (done) => { utils.stream_test(make_request('store', writes), (err, res) => { assert.ifError(err); const latest_write = check_and_get_latest_write(res); check_collection([ Object.assign({ [hz_v]: 2 }, latest_write) ], done); }); }); it('Replace', (done) => { utils.stream_test(make_request('replace', writes), (err, res) => { assert.ifError(err); const latest_write = check_and_get_latest_write(res); check_collection([ Object.assign({ [hz_v]: 2 }, latest_write) ], done); }); }); it('Upsert', (done) => { utils.stream_test(make_request('upsert', writes), (err, res) => { assert.ifError(err); check_and_get_latest_write(res); check_collection([ { id: 0, value: 0, a: 1, b: 2, c: 3, [hz_v]: 2 } ], done); }); }); it('Update', (done) => { utils.stream_test(make_request('update', writes), (err, res) => { assert.ifError(err); check_and_get_latest_write(res); check_collection([ { id: 0, value: 0, a: 1, b: 2, c: 3, [hz_v]: 2 } ], done); }); }); it('Remove', (done) => { utils.stream_test(make_request('remove', writes), (err, res) => { assert.ifError(err); assert.deepStrictEqual(res.map((x) => x[hz_v]).sort(), [ undefined, undefined, undefined ]); assert.deepStrictEqual(res.map((x) => x.id), [ 0, 0, 0 ]); check_collection([ ], done); }); }); }); describe('New Row', () => { it('Insert', (done) => { utils.stream_test(make_request('insert', writes), (err, res) => { assert.ifError(err); const success_write = check_one_successful_write(res, 'The document already exists.'); check_collection([ Object.assign({ [hz_v]: 0 }, success_write) ], done); }); }); it('Store', (done) => { utils.stream_test(make_request('store', writes), (err, res) => { assert.ifError(err); const latest_write = check_and_get_latest_write(res); check_collection([ Object.assign({ [hz_v]: 2 }, latest_write) ], done); }); }); it('Upsert', (done) => { utils.stream_test(make_request('upsert', writes), (err, res) => { assert.ifError(err); assert.deepStrictEqual(res.map((x) => x[hz_v]).sort(), [ 0, 1, 2 ]); assert.deepStrictEqual(res.map((x) => x.id), [ 0, 0, 0 ]); check_collection([ { id: 0, a: 1, b: 2, c: 3, [hz_v]: 2 } ], done); }); }); }); // Because all the writes are to the same document, only one can succeed // per iteration with the database. In order to test timeouts, we use a // timeout of zero, so the other rows should immediately error. describe('Zero Timeout', () => { const timeout = { timeout: 0 }; const test_data = [ { id: 0, value: 0 } ]; beforeEach('Populate collection', (done) => utils.populate_collection(collection, test_data, done)); it('Store', (done) => { utils.stream_test(make_request('store', writes, timeout), (err, res) => { assert.ifError(err); const success_write = check_one_successful_write(res, 'Operation timed out.'); check_collection([ Object.assign({ [hz_v]: 0 }, success_write) ], done); }); }); it('Replace', (done) => { utils.stream_test(make_request('replace', writes, timeout), (err, res) => { assert.ifError(err); const success_write = check_one_successful_write(res, 'Operation timed out.'); check_collection([ Object.assign({ [hz_v]: 0 }, success_write) ], done); }); }); it('Upsert', (done) => { utils.stream_test(make_request('upsert', writes, timeout), (err, res) => { assert.ifError(err); const success_write = check_one_successful_write(res, 'Operation timed out.'); check_collection([ Object.assign({ [hz_v]: 0 }, test_data[0], success_write) ], done); }); }); it('Update', (done) => { utils.stream_test(make_request('update', writes, timeout), (err, res) => { assert.ifError(err); const success_write = check_one_successful_write(res, 'Operation timed out.'); check_collection([ Object.assign({ [hz_v]: 0 }, test_data[0], success_write) ], done); }); }); }); }); }; const suite = (collection) => describe('Write', () => all_tests(collection)); module.exports = { suite }; ================================================ FILE: test/serve.js ================================================ #!/usr/bin/env node 'use strict' Error.stackTraceLimit = Infinity; const horizon = require('../server'); // Utilities provided by the CLI library const each_line_in_pipe = require('../cli/src/utils/each_line_in_pipe'); const start_rdb_server = require('../cli/src/utils/start_rdb_server'); const rm_sync_recursive = require('../cli/src/utils/rm_sync_recursive'); const parse_yes_no_option = require('../cli/src/utils/parse_yes_no_option'); // We could make this a module, but we already require the server to be configured, // so reuse its argparse module const argparse = require('../cli/node_modules/argparse'); const assert = require('assert'); const child_process = require('child_process'); const dns = require('dns'); const fs = require('fs'); const http = require('http'); const path = require('path'); const url = require('url'); const process = require('process'); const crypto = require('crypto'); const data_dir = path.resolve(__dirname, 'rethinkdb_data_test'); const test_dist_dir = path.resolve(__dirname, '../client/dist'); const examples_dir = path.resolve(__dirname, '../examples'); const parser = new argparse.ArgumentParser(); parser.addArgument([ '--port', '-p' ], { type: 'int', defaultValue: 8181, metavar: 'PORT', help: 'Local port to serve HTTP assets and horizon on.' }); parser.addArgument([ '--bind', '-b' ], { type: 'string', defaultValue: [ 'localhost' ], action: 'append', metavar: 'HOST', help: 'Local hostname(s) to serve HTTP and horizon on (repeatable).' }); parser.addArgument([ '--keep', '-k' ], { defaultValue: false, action: 'storeTrue', help: 'Keep the existing "rethinkdb_data_test" directory.' }); parser.addArgument([ '--permissions' ], { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', defaultValue: 'no', help: 'Enable or disable checking permissions on requests, defaults to disabled.' }); const options = parser.parseArgs(); if (options.bind.indexOf('all') !== -1) { options.bind = [ '0.0.0.0' ]; } process.on('SIGINT', () => process.exit(0)); process.on('SIGTERM', () => process.exit(0)); const serve_file = (file_path, res) => { fs.access(file_path, fs.R_OK | fs.F_OK, (exists) => { if (exists) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end(`File "${file_path}" not found\n`); } else { fs.readFile(file_path, 'binary', (err, file) => { if (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end(`${err}\n`); } else { if (file_path.endsWith('.js')) { res.writeHead(200, { 'Content-Type': 'application/javascript' }); } else if (file_path.endsWith('.html')) { res.writeHead(200, { 'Content-Type': 'text/html' }); } else { res.writeHead(200); } res.end(file, 'binary'); } }); } }); }; // On Windows, `npm` is actually `npm.cmd` const npm_cmd = process.platform === "win32" ? "npm.cmd" : "npm"; // Run the client build const build_proc = child_process.spawn(npm_cmd, [ 'run', 'dev'], { cwd: test_dist_dir }); build_proc.on('exit', () => process.exit(1)); process.on('exit', () => build_proc.kill('SIGTERM')); let client_ready = false; each_line_in_pipe(build_proc.stdout, (line) => { console.log(line); if (/horizon.js[^.]/.test(line)) { setImmediate(() => { client_ready = true; const date = new Date(); console.log(`${date.toLocaleTimeString()} - horizon.js rebuilt.`); }); } }); each_line_in_pipe(build_proc.stderr, (line) => { console.error(line); }); build_proc.stderr.on('data', (data) => { const str = data.toString(); if (str.indexOf('% compile') >= 0) { const date = new Date(); console.log(`${date.toLocaleTimeString()} - client assets compile.`); } if (str.indexOf('% emit') >= 0) { const date = new Date(); console.log(`${date.toLocaleTimeString()} - client assets emit.`); } }); // Launch HTTP server with horizon that will serve the test files const http_servers = options.bind.map((host) => new http.Server((req, res) => { const req_path = url.parse(req.url).pathname; if (req_path.indexOf('/examples/') === 0) { serve_file(path.resolve(examples_dir, req_path.replace(/^[/]examples[/]/, '')), res); } else { if (!client_ready) { res.writeHead(503, { 'Content-Type': 'text/plain' }); res.end('Initial client build is ongoing, try again in a few seconds.'); } else { serve_file(path.resolve(test_dist_dir, req_path.replace(/^[/]/, '')), res); } } })); // Determine the local IP addresses to tell `rethinkdb` to bind on new Promise((resolve) => { let outstanding = options.bind.length; const res = new Set(); const add_results = (err, addrs) => { assert.ifError(err); addrs.forEach((addr) => { // Filter out link-local addresses since node doesn't tell us the scope-id if (addr.address.indexOf('fe80') !== 0) { res.add(addr.address); } }); outstanding -= 1; if (outstanding === 0) { resolve(res); } }; options.bind.forEach((host) => { dns.lookup(host, { all: true }, add_results); }); }).then((local_addresses) => { // Launch rethinkdb - once we know the port we can attach horizon to the http server if (!options.keep) { rm_sync_recursive(data_dir); } console.log('starting rethinkdb'); return start_rdb_server({ bind: local_addresses, dataDir: data_dir }); }).then((server) => { assert.notStrictEqual(server.driver_port, undefined); console.log(`RethinkDB server listening for clients on port ${server.driver_port}.`); console.log(`RethinkDB server listening for HTTP on port ${server.http_port}.`); console.log('starting horizon'); horizon.logger.level = 'debug'; const horizon_server = new horizon.Server(http_servers, { auto_create_collection: true, auto_create_index: true, rdb_port: server.driver_port, permissions: parse_yes_no_option(options.permissions), project_name: 'test', auth: { allow_unauthenticated: true, allow_anonymous: true, token_secret: crypto.randomBytes(64).toString('base64'), }, }); console.log('starting http servers'); // Capture requests to `horizon.js` and `horizon.js.map` before the horizon server http_servers.forEach((serv, i) => { const extant_listeners = serv.listeners('request').slice(0); serv.removeAllListeners('request'); serv.on('request', (req, res) => { const req_path = url.parse(req.url).pathname; if (req_path === '/horizon/horizon.js' || req_path === '/horizon/horizon.js.map') { serve_file(path.resolve(test_dist_dir, req_path.replace('/horizon/', '')), res); } else { extant_listeners.forEach((l) => l.call(serv, req, res)); } }); serv.listen(options.port, options.bind[i], () => console.log(`HTTP server listening on ${options.bind[i]}:${options.port}.`)); }); }).catch((err) => { console.log(`Error when starting server:\n${err.stack}`); process.exit(1); }); ================================================ FILE: test/setupDev.sh ================================================ #!/bin/bash set -e green () { echo -e "\033[1;32m== $1 \033[0m" } if [ "$1" == '--clean' ]; then green 'Removing old node_modules dirs if present' if [ -d ../client/node_modules ]; then echo Removing client/node_modules rm -r ../client/node_modules fi if [ -d ../server/node_modules ]; then echo Removing server/node_modules rm -r ../server/node_modules fi if [ -d ../cli/node_modules ]; then echo Removing cli/node_modules rm -r ../cli/node_modules fi fi pushd ../client green 'Unlinking existing client' npm unlink green 'Linking client' npm link --unsafe-perm --cache-min 9999999 popd pushd ../server green 'Unlinking existing server' npm unlink green 'Linking server' npm link @horizon/client npm link --cache-min 9999999 popd pushd ../cli green 'Unlinking existing horizon cli' npm unlink green 'Linking horizon cli' npm link @horizon/server npm link --cache-min 9999999 popd green 'Dev environment set up' ================================================ FILE: update_versions.py ================================================ #!/usr/bin/env python2 '''What? A Python script in a JavaScript library? Well I never... This script is just for updating versions of Horizon, it doesn't get packaged or have any use for consumers of Horizon itself. ''' import json import sys from contextlib import contextmanager from collections import OrderedDict @contextmanager def rewrite(filename): with open(filename, 'rb') as f: package_json = json.load(f, object_pairs_hook=OrderedDict) yield package_json with open(filename, 'wb') as f: json.dump(package_json, f, indent=2, separators=(',', ': ')) f.write('\n') # json dump gives no trailing newline def main(version): with rewrite('./client/package.json') as client_pkg: client_pkg['version'] = version with rewrite('./server/package.json') as server_pkg: server_pkg['version'] = version server_pkg['dependencies']['@horizon/client'] = version with rewrite('./cli/package.json') as cli_pkg: cli_pkg['version'] = version cli_pkg['dependencies']['@horizon/server'] = version if __name__ == '__main__': try: main(sys.argv[1]) except: print 'Please provide a version'