diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 000000000..873575d4b --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,25 @@ +{ + "projectName": "racer", + "projectOwner": "derbyjs", + "repoType": "github", + "repoHost": "https://github.com", + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": true, + "commitConvention": "none", + "contributors": [ + { + "login": "craigbeck", + "name": "Craig Beck", + "avatar_url": "https://avatars.githubusercontent.com/u/1620605?v=4", + "profile": "http://craigbeck.io/", + "contributions": [ + "test", + "code" + ] + } + ], + "contributorsPerLine": 7 +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..18030cc76 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,46 @@ +// The ESLint ecmaVersion argument is inconsistently used. Some rules will ignore it entirely, so if the rule has +// been set, it will still error even if it's not applicable to that version number. Since Google sets these +// rules, we have to turn them off ourselves. +var DISABLED_ES6_OPTIONS = { + 'no-var': 'off', + 'prefer-rest-params': 'off', + 'prefer-spread': 'off', + // Not supported in ES3 + 'comma-dangle': ['error', 'never'] +}; + +var CUSTOM_RULES = { + 'one-var': 'off', + // We control our own objects and prototypes, so no need for this check + 'guard-for-in': 'off', + // Google prescribes different indents for different cases. Let's just use 2 spaces everywhere. Note that we have + // to override ESLint's default of 0 indents for this. + 'indent': ['error', 2, {'SwitchCase': 1}], + // Less aggressive line length than Google, which is especially useful when we have a lot of callbacks in our code + 'max-len': ['error', {code: 120, tabWidth: 2, ignoreUrls: true}], + // Go back to default ESLint behaviour here, which is slightly better for catching erroneously unused variables + 'no-unused-vars': ['error', {vars: 'all', args: 'after-used'}], + 'require-jsdoc': 'off', + 'valid-jsdoc': 'off' +}; + +module.exports = { + extends: 'google', + ignorePatterns: ['.gitignore', 'lib/**/*.js'], + parserOptions: { + ecmaVersion: 5 + }, + rules: Object.assign( + {}, + DISABLED_ES6_OPTIONS, + CUSTOM_RULES + ), + overrides: [ + { + files: ['test/**/*.js'], + parserOptions: { + ecmaVersion: 2017 + } + } + ] +}; diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 000000000..d4393bf1f --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,62 @@ +# Based on https://github.com/actions/starter-workflows/blob/main/pages/jekyll.yml +name: Build typedoc docs site, Deploy to Pages when on default branch + +env: + DOCS_DIR: docs + +on: + # Run workflow on any branch push. + # Conditionals are used to only trigger deploy on the default branch. + push: + # Uncomment to only run on specific branch pushes. + # branches: ["master"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment per branch, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow the deployments to complete. +concurrency: + group: "pages-${{ github.ref }}" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Setup Pages + id: pages + uses: actions/configure-pages@v4 + - name: Build with Typedoc + run: npm i && npm run docs + - name: Upload artifact + if: github.ref == 'refs/heads/master' # Only upload when on default branch + uses: actions/upload-pages-artifact@v3 + with: + path: "./${{ env.DOCS_DIR }}" + + # Deployment job + deploy: + if: github.ref == 'refs/heads/master' # Only deploy when on default branch + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..f08e29773 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +# https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs-or-python + + +name: Test + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + name: Node.js ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + matrix: + node: + - 16 + - 18 + - 20 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + - run: npm install + - run: npm run lint + if: ${{ matrix.node >= 12 }} # eslint@8 only supports Node >= 12 + - run: npm run test-cover + # https://github.com/marketplace/actions/coveralls-github-action#complete-parallel-job-example + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: node-${{ matrix.node }} + parallel: true + + finish: + needs: test + runs-on: ubuntu-latest + steps: + - name: Submit coverage + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true diff --git a/.gitignore b/.gitignore index 8e9f2d6de..6b679a8cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .DS_Store *.swp node_modules +coverage +lib/ + +.env diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index de9cb6572..000000000 --- a/.jshintrc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "node": true, - "laxcomma": true, - "eqnull": true, - "eqeqeq": true, - "indent": 2, - "newcap": true, - "quotmark": "single", - "undef": true, - "trailing": true, - "shadow": true, - "expr": true, - "boss": true -} diff --git a/.mocharc.yml b/.mocharc.yml new file mode 100644 index 000000000..815f9301e --- /dev/null +++ b/.mocharc.yml @@ -0,0 +1,4 @@ +reporter: spec +timeout: 1200 +check-leaks: true +recursive: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6e5919de3..000000000 --- a/.travis.yml +++ /dev/null @@ -1,3 +0,0 @@ -language: node_js -node_js: - - "0.10" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..def0f3701 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# v2.1.0 (Fri May 24 2024) + +#### 🚀 Enhancement + +- Add methods getOrDefault and getOrThrow [#307](https://github.com/derbyjs/racer/pull/307) ([@craigbeck](https://github.com/craigbeck)) +- Add getValues method for fetching array of docs from collection [#306](https://github.com/derbyjs/racer/pull/306) ([@craigbeck](https://github.com/craigbeck)) + +#### 🐛 Bug Fix + +- Resolve `addPromised` with new doc `id` [#305](https://github.com/derbyjs/racer/pull/305) ([@craigbeck](https://github.com/craigbeck)) + +#### ⚠️ Pushed to `master` + +- Ignore .env file ([@craigbeck](https://github.com/craigbeck)) + +#### Authors: 1 + +- Craig Beck ([@craigbeck](https://github.com/craigbeck)) + +--- + +# v2.1.0 (Fri May 24 2024) + +#### 🚀 Enhancement + +- Add methods getOrDefault and getOrThrow [#307](https://github.com/derbyjs/racer/pull/307) ([@craigbeck](https://github.com/craigbeck)) +- Add getValues method for fetching array of docs from collection [#306](https://github.com/derbyjs/racer/pull/306) ([@craigbeck](https://github.com/craigbeck)) + +#### 🐛 Bug Fix + +- Resolve `addPromised` with new doc `id` [#305](https://github.com/derbyjs/racer/pull/305) ([@craigbeck](https://github.com/craigbeck)) + +#### Authors: 1 + +- Craig Beck ([@craigbeck](https://github.com/craigbeck)) diff --git a/README.md b/README.md index 57921997d..ca14e6272 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,21 @@ # Racer -Racer is a realtime model synchronization engine for Node.js. By leveraging [ShareJS](http://sharejs.org/), multiple users can interact with the same data in realtime via Operational Transformation, a sophisticated conflict resolution algorithm that works in realtime and with offline clients. ShareJS also supports PubSub across multiple servers for horizontal scaling. Clients can express data subscriptions and fetches in terms of queries and specific documents, so different clients can be subscribed to different overlapping sets of data. On top of this sophisticated backend, Racer provides a simple model and event interface for writing application logic. - -[![Build -Status](https://secure.travis-ci.org/codeparty/racer.png?branch=0.5)](https://travis-ci.org/codeparty/racer/branches) - -## Disclaimer - -Racer is alpha software. We are now on the road to a production ready version, but we are currently cleaning up the code, finishing loose ends, and testing. - -If you are interested in contributing, please reach out to [Nate](https://github.com/nateps), [Joseph](https://github.com/josephg), and [Brian](https://github.com/bnoguchi). +Racer is a realtime model synchronization engine for Node.js. By leveraging [ShareDB](https://github.com/share/sharedb), multiple users can interact with the same data in realtime via Operational Transformation, a sophisticated conflict resolution algorithm that works in realtime and with offline clients. ShareDB also supports PubSub across multiple servers for horizontal scaling. Clients can express data subscriptions and fetches in terms of queries and specific documents, so different clients can be subscribed to different overlapping sets of data. On top of this sophisticated backend, Racer provides a simple model and event interface for writing application logic. ## Demos -There are currently two demos, which are included under the examples directory. See [Installation](#installation) below. - -### Pad - - - -A very simple collaborative [text editor](https://github.com/codeparty/racer-examples/tree/master/pad). - -### Todos - - - -Classic [todo list](https://github.com/codeparty/racer-examples/tree/master/todos) demonstrating the use of Racer's model methods. +There are currently two demos, which are included in the [racer-examples](https://github.com/derbyjs/racer-examples) repo. + * [Pad](https://github.com/derbyjs/racer-examples/tree/master/pad) – A very simple collaborative text editor. + * [Todos](https://github.com/derbyjs/racer-examples/tree/master/todos) – Classic todo list demonstrating the use of Racer's model methods. ## Features - * **Realtime updates** – Model methods automatically propagate changes among browser clients and Node servers in realtime. The [racer-browserchannel](https://github.com/codeparty/racer-browserchannel) adapter is recommended for connecting browsers in realtime. + * **Realtime updates** – Model methods automatically propagate changes among browser clients and Node servers in realtime. The [racer-browserchannel](https://github.com/derbyjs/racer-browserchannel) adapter is recommended for connecting browsers in realtime. * **Realtime query subscriptions** – Clients may subscribe to a limited set of information relevant to the current session. Both document and realtime query subscriptions are supported. Currently, arbitrary Mongo queries are supported. - * **Conflict resolution** – Leveraging ShareJS's JSON Operational Transformation algorithm, Racer will emit events that bring conflicting client states into eventual consistency. In addition to their synchronous API, model methods have callbacks for handling the resolved state after a server response. + * **Conflict resolution** – Leveraging ShareDB's JSON Operational Transformation algorithm, Racer will emit events that bring conflicting client states into eventual consistency. In addition to their synchronous API, model methods have callbacks for handling the resolved state after a server response. * **Immediate interaction** – Model methods appear to take effect immediately. Meanwhile, Racer sends updates to the server and checks for conflicts. If the updates are successful, they are stored and broadcast to other clients. @@ -42,62 +23,37 @@ Classic [todo list](https://github.com/codeparty/racer-examples/tree/master/todo * **Unified server and client interface** – The same model interface can be used on the server for initial page rendering and on the client for user interaction. Racer supports bundling models created on the server and reinitializing them in the same state in the browser. - * **Persistent storage** – Racer/ShareJS use [LiveDB](https://github.com/josephg/livedb) to keep a journal of all data operations, publish operations to multiple frontend servers, and automatically persist documents. It currently supports MongoDB, and it can be easily adapted to support other document stores. + * **Persistent storage** – Racer uses [ShareDB](https://github.com/share/sharedb) to keep a journal of all data operations, publish operations to multiple frontend servers, and automatically persist documents. It currently supports MongoDB, and it can be easily adapted to support other document stores. * **Access control** – (Under development) Racer will have hooks for access control to protect documents from malicious reads and writes. * **Solr queries** – (Under development) A Solr adapter will support updating Solr indices as data change and queries for realtime updated search results. -## Future features - - * **Browser local storage** – Pending changes and offline model data will also sync to HTML5 localStorage for persistent offline usage. - - * **Validation** – An implementation of shared and non-shared schema-based validation is planned. - - ## Installation -Racer requires [Node v0.10](http://nodejs.org/). You will also need to have a [MongoDB](http://docs.mongodb.org/manual/installation/) and a [Redis](http://redis.io/download) server running on your machine. The examples will connect via the default configurations. +Racer requires [Node v16](http://nodejs.org/). You will also need to have a [MongoDB](http://docs.mongodb.org/manual/installation/) and a [Redis](http://redis.io/download) server running on your machine. The examples will connect via the default configurations. ``` $ npm install racer ``` -The examples can then be run by: - -``` -$ cd node_modules/racer/examples/pad -$ npm install -$ node server.js -``` - -and - -``` -$ cd node_modules/racer/examples/todos -$ npm install -$ npm install -g coffee-script -$ coffee server.coffee -``` - ## Tests Run the tests with ``` -$ npm install -g grunt-cli -$ grunt test +$ npm test ``` ## Usage Racer can be used independently as shown in the examples, but Racer and Derby are designed to work especially well together. Racer can also be used along with other MVC frameworks, such as Angular. -For now, Racer is mostly documented along with Derby. See the Derby [model docs](http://derbyjs.com/#models). +For now, Racer is mostly documented along with Derby. See the Derby [model docs](https://derbyjs.github.io/derby/models). ### MIT License -Copyright (c) 2011 by Brian Noguchi and Nate Smith +Copyright (c) 2024 by Brian Noguchi and Nate Smith Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -116,3 +72,4 @@ 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. + diff --git a/lib/Channel.js b/lib/Channel.js deleted file mode 100644 index fab842b6d..000000000 --- a/lib/Channel.js +++ /dev/null @@ -1,92 +0,0 @@ -var EventEmitter = require('events').EventEmitter; -var util = require('./util'); - -module.exports = Channel; - -function Channel(socket) { - EventEmitter.call(this); - - this.socket = socket; - this.messages = new Messages(); - - var channel = this; - var onmessage = socket.onmessage; - socket.onmessage = function(message) { - var data = message.data; - if (typeof data === 'string') data = JSON.parse(data); - - if (data && data.racer) return channel._onMessage(data); - onmessage && onmessage.call(socket, message); - }; -} - -util.mergeInto(Channel.prototype, EventEmitter.prototype); - -Channel.prototype.send = function(name, data, cb) { - var message = this.messages.add(name, data, cb); - // Proactively call the toJSON function, since the Google Closure JSON - // serializer doesn't check for it - this.socket.send(message.toJSON()); -}; - -Channel.prototype._reply = function(id, name, data) { - var message = new Message(id, true, name, data); - this.socket.send(message.toJSON()); -}; - -Channel.prototype._onMessage = function(data) { - if (data.ack) { - var message = this.messages.remove(data.id); - if (message && message.cb) message.cb.apply(null, data.data); - return; - } - var name = data.racer; - if (data.cb) { - var channel = this; - var hasListeners = this.emit(name, data.data, function() { - var args = Array.prototype.slice.call(arguments); - channel._reply(data.id, name, args); - }); - if (!hasListeners) this._reply(data.id, name); - } else { - this.emit(name, data.data); - this._reply(data.id, name); - } -}; - -function MessagesMap() {} - -function Messages() { - this.map = new MessagesMap(); - this.idCount = 0; -} -Messages.prototype.id = function() { - return (++this.idCount).toString(36); -}; -Messages.prototype.add = function(name, data, cb) { - var message = new Message(this.id(), false, name, data, cb); - this.map[message.id] = message; - return message; -}; -Messages.prototype.remove = function(id) { - var message = this.map[id]; - delete this.map[id]; - return message; -}; - -function Message(id, ack, name, data, cb) { - this.id = id; - this.ack = ack; - this.name = name; - this.data = data; - this.cb = cb; -} -Message.prototype.toJSON = function() { - return { - racer: this.name - , id: this.id - , data: this.data - , ack: +this.ack - , cb: (this.cb) ? 1 : 0 - }; -}; diff --git a/lib/Model/Doc.js b/lib/Model/Doc.js deleted file mode 100644 index 6eddfb6aa..000000000 --- a/lib/Model/Doc.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = Doc; - -function Doc(model, collectionName, id) { - this.collectionName = collectionName; - this.id = id; - this.collectionData = model && model.data[collectionName]; -} - -Doc.prototype.path = function(segments) { - return this.collectionName + '.' + this.id + '.' + segments.join('.'); -}; - -Doc.prototype._errorMessage = function(description, segments, value) { - return description + ' at ' + this.path(segments) + ': ' + - JSON.stringify(value, null, 2); -}; diff --git a/lib/Model/LocalDoc.js b/lib/Model/LocalDoc.js deleted file mode 100644 index a3b185f51..000000000 --- a/lib/Model/LocalDoc.js +++ /dev/null @@ -1,210 +0,0 @@ -var Doc = require('./Doc'); -var util = require('../util'); - -module.exports = LocalDoc; - -function LocalDoc(model, collectionName, id, snapshot) { - Doc.call(this, model, collectionName, id); - this.snapshot = snapshot; - this._updateCollectionData(); -} - -LocalDoc.prototype = new Doc(); - -LocalDoc.prototype._updateCollectionData = function() { - this.collectionData[this.id] = this.snapshot; -}; - -LocalDoc.prototype.set = function(segments, value, cb) { - function set(node, key) { - var previous = node[key]; - node[key] = value; - return previous; - } - return this._apply(segments, set, cb); -}; - -LocalDoc.prototype.del = function(segments, cb) { - // Don't do anything if the value is already undefined, since - // apply creates objects as it traverses, and the del method - // should not create anything - var previous = this.get(segments); - if (previous === void 0) { - cb(); - return; - } - function del(node, key) { - delete node[key]; - return previous; - } - return this._apply(segments, del, cb); -}; - -LocalDoc.prototype.increment = function(segments, byNumber, cb) { - var self = this; - function validate(value) { - if (typeof value === 'number' || value == null) return; - return new TypeError(self._errorMessage( - 'increment on non-number', segments, value - )); - } - function increment(node, key) { - var value = (node[key] || 0) + byNumber; - node[key] = value; - return value; - } - return this._validatedApply(segments, validate, increment, cb); -}; - -LocalDoc.prototype.push = function(segments, value, cb) { - function push(arr) { - return arr.push(value); - } - return this._arrayApply(segments, push, cb); -}; - -LocalDoc.prototype.unshift = function(segments, value, cb) { - function unshift(arr) { - return arr.unshift(value); - } - return this._arrayApply(segments, unshift, cb); -}; - -LocalDoc.prototype.insert = function(segments, index, values, cb) { - function insert(arr) { - arr.splice.apply(arr, [index, 0].concat(values)); - return arr.length; - } - return this._arrayApply(segments, insert, cb); -}; - -LocalDoc.prototype.pop = function(segments, cb) { - function pop(arr) { - return arr.pop(); - } - return this._arrayApply(segments, pop, cb); -}; - -LocalDoc.prototype.shift = function(segments, cb) { - function shift(arr) { - return arr.shift(); - } - return this._arrayApply(segments, shift, cb); -}; - -LocalDoc.prototype.remove = function(segments, index, howMany, cb) { - function remove(arr) { - return arr.splice(index, howMany); - } - return this._arrayApply(segments, remove, cb); -}; - -LocalDoc.prototype.move = function(segments, from, to, howMany, cb) { - function move(arr) { - // Remove from old location - var values = arr.splice(from, howMany); - // Insert in new location - arr.splice.apply(arr, [to, 0].concat(values)); - return values; - } - return this._arrayApply(segments, move, cb); -}; - -LocalDoc.prototype.stringInsert = function(segments, index, value, cb) { - var self = this; - function validate(value) { - if (typeof value === 'string' || value == null) return; - return new TypeError(self._errorMessage( - 'stringInsert on non-string', segments, value - )); - } - function stringInsert(node, key) { - var previous = node[key]; - if (previous == null) { - node[key] = value; - return previous; - } - node[key] = previous.slice(0, index) + value + previous.slice(index); - return previous; - } - return this._validatedApply(segments, validate, stringInsert, cb); -}; - -LocalDoc.prototype.stringRemove = function(segments, index, howMany, cb) { - var self = this; - function validate(value) { - if (typeof value === 'string' || value == null) return; - return new TypeError(self._errorMessage( - 'stringRemove on non-string', segments, value - )); - } - function stringRemove(node, key) { - var previous = node[key]; - if (previous == null) return previous; - if (index < 0) index += previous.length; - node[key] = previous.slice(0, index) + previous.slice(index + howMany); - return previous; - } - return this._validatedApply(segments, validate, stringRemove, cb); -}; - -LocalDoc.prototype.get = function(segments) { - return util.lookup(segments, this.snapshot); -}; - -/** - * @param {Array} segments is the array representing a path - * @param {Function} fn(node, key) applies a mutation on node[key] - * @return {Object} returns the return value of fn(node, key) - */ -LocalDoc.prototype._createImplied = function(segments, fn) { - var node = this; - var key = 'snapshot'; - var i = 0; - var nextKey = segments[i++]; - while (nextKey != null) { - // Get or create implied object or array - node = node[key] || (node[key] = /^\d+$/.test(nextKey) ? [] : {}); - key = nextKey; - nextKey = segments[i++]; - } - return fn(node, key); -}; - -LocalDoc.prototype._apply = function(segments, fn, cb) { - var out = this._createImplied(segments, fn); - this._updateCollectionData(); - cb(); - return out; -}; - -LocalDoc.prototype._validatedApply = function(segments, validate, fn, cb) { - var out = this._createImplied(segments, function(node, key) { - var err = validate(node[key]); - if (err) return cb(err); - return fn(node, key); - }); - this._updateCollectionData(); - cb(); - return out; -}; - -LocalDoc.prototype._arrayApply = function(segments, fn, cb) { - // Lookup a pointer to the property or nested property & - // return the current value or create a new array - var arr = this._createImplied(segments, nodeCreateArray); - - if (!Array.isArray(arr)) { - var message = this._errorMessage(fn.name + ' on non-array', segments, arr); - var err = new TypeError(message); - return cb(err); - } - var out = fn(arr); - this._updateCollectionData(); - cb(); - return out; -}; - -function nodeCreateArray(node, key) { - return node[key] || (node[key] = []); -} diff --git a/lib/Model/Model.js b/lib/Model/Model.js deleted file mode 100644 index 26a79ada7..000000000 --- a/lib/Model/Model.js +++ /dev/null @@ -1,42 +0,0 @@ -var uuid = require('node-uuid'); - -Model.INITS = []; - -module.exports = Model; - -function Model(options) { - this.root = this; - - var inits = Model.INITS; - options || (options = {}); - for (var i = 0; i < inits.length; i++) { - inits[i](this, options); - } -} - -Model.prototype.id = function() { - return uuid.v4(); -}; - -Model.prototype._child = function() { - return new ChildModel(this); -}; - -function ChildModel(model) { - // Shared properties should be accessed via the root. This makes inheritance - // cheap and easily extensible - this.root = model.root; - - // EventEmitter methods access these properties directly, so they must be - // inherited manually instead of via the root - this._events = model._events; - this._maxListeners = model._maxListeners; - - // Properties specific to a child instance - this._context = model._context; - this._at = model._at; - this._pass = model._pass; - this._silent = model._silent; - this._eventContext = model._eventContext; -} -ChildModel.prototype = new Model(); diff --git a/lib/Model/Query.js b/lib/Model/Query.js deleted file mode 100644 index 189893aab..000000000 --- a/lib/Model/Query.js +++ /dev/null @@ -1,596 +0,0 @@ -var util = require('../util'); -var Model = require('./Model'); -var arrayDiff = require('arraydiff'); - -module.exports = Query; - -Model.INITS.push(function(model) { - model.root._queries = new Queries(); - if (model.root.fetchOnly) return; - model.on('all', function(segments) { - var map = model.root._queries.map; - for (var hash in map) { - var query = map[hash]; - if (query.isPathQuery && query.shareQuery && util.mayImpact(query.expression, segments)) { - var ids = pathIds(model, query.expression); - var previousIds = model._get(query.idsSegments); - query._onChange(ids, previousIds); - } - } - }); -}); - -/** - * @param {String} collectionName - * @param {Object} expression - * @param {String} source - * @return {Query} - */ -Model.prototype.query = function(collectionName, expression, source) { - if (typeof expression.path === 'function' || typeof expression !== 'object') { - expression = this._splitPath(expression); - } - var query = this.root._queries.get(collectionName, expression, source); - if (query) return query; - query = new Query(this, collectionName, expression, source); - this.root._queries.add(query); - return query; -}; - -/** - * Called during initialization of the bundle on page load. - */ -Model.prototype._initQueries = function(items) { - var queries = this.root._queries; - for (var i = 0; i < items.length; i++) { - var item = items[i]; - var counts = item[0]; - var collectionName = item[1]; - var expression = item[2]; - var ids = item[3] || []; - var snapshots = item[4] || []; - var versions = item[5] || []; - var source = item[6]; - var extra = item[7]; - var query = new Query(this, collectionName, expression, source); - queries.add(query); - - this._set(query.idsSegments, ids); - - // This is a bit of a hack, but it should be correct. Given that queries - // are initialized first, the ids path is probably not set yet, but it will - // be used to generate the query. Therefore, we assume that the value of - // path will be the ids that the query results were on the server. There - // are probably some really odd edge cases where this doesn't work, and - // a more correct thing to do would be to get the actual value for the - // path before creating the query subscription. This feature should - // probably be rethought. - if (query.isPathQuery) { - this._setNull(expression, ids); - } - - if (extra !== void 0) { - this._set(query.extraSegments, extra); - } - - for (var j = 0; j < snapshots.length; j++) { - var snapshot = snapshots[j]; - if (!snapshot) continue; - var id = ids[j]; - var version = versions[j]; - var data = {data: snapshot, v: version, type: 'json0'}; - this.getOrCreateDoc(collectionName, id, data); - this._loadVersions[collectionName + '.' + id] = version; - } - - for (var j = 0; j < counts.length; j++) { - var count = counts[j]; - var subscribed = count[0] || 0; - var fetched = count[1] || 0; - var contextId = count[2]; - if (contextId) query.model.setContext(contextId); - while (subscribed--) { - query.subscribe(); - } - query.fetchCount += fetched; - while (fetched--) { - query.fetchIds.push(ids); - query.model._context.fetchQuery(query); - var alreadyLoaded = true; - for (var k = 0; k < ids.length; k++) { - query.model.fetchDoc(collectionName, ids[k], null, alreadyLoaded); - } - } - } - } -}; - -function QueriesMap() {} - -function Queries() { - this.map = new QueriesMap(); -} -Queries.prototype.add = function(query) { - this.map[query.hash] = query; -}; -Queries.prototype.remove = function(query) { - delete this.map[query.hash]; -}; -Queries.prototype.get = function(collectionName, expression, source) { - var hash = queryHash(collectionName, expression, source); - return this.map[hash]; -}; -Queries.prototype.toJSON = function() { - var out = []; - for (var hash in this.map) { - var query = this.map[hash]; - if (query.subscribeCount || query.fetchCount) { - out.push(query.serialize()); - } - } - return out; -}; - -/** - * @private - * @constructor - * @param {Model} model - * @param {Object} collectionName - * @param {Object} expression - * @param {String} source (e.g., 'solr') - * @param {Number} subscribeCount - * @param {Number} fetchCount - * @param {Array>} fetchIds - */ -function Query(model, collectionName, expression, source) { - this.model = model.pass({$query: this}); - this.collectionName = collectionName; - this.expression = expression; - this.source = source; - this.hash = queryHash(collectionName, expression, source); - this.segments = ['$queries', this.hash]; - this.idsSegments = ['$queries', this.hash, 'ids']; - this.extraSegments = ['$queries', this.hash, 'extra']; - this.isPathQuery = Array.isArray(expression); - - this._pendingSubscribeCallbacks = []; - - // These are used to help cleanup appropriately when calling unsubscribe and - // unfetch. A query won't be fully cleaned up until unfetch and unsubscribe - // are called the same number of times that fetch and subscribe were called. - this.subscribeCount = 0; - this.fetchCount = 0; - // The list of ids at the time of each fetch is pushed onto fetchIds, so - // that unfetchDoc can be called the same number of times as fetchDoc - this.fetchIds = []; - - this.created = false; - this.shareQuery = null; -} - -Query.prototype.create = function() { - this.created = true; - this.model.root._queries.add(this); -}; - -Query.prototype.destroy = function() { - this.created = false; - if (this.shareQuery) { - this.shareQuery.destroy(); - this.shareQuery = null; - } - this.model.root._queries.remove(this); - this.model._del(this.segments); -}; - -Query.prototype.sourceQuery = function() { - if (this.isPathQuery) { - var ids = pathIds(this.model, this.expression); - return {_id: {$in: ids}}; - } - return this.expression; -}; - -/** - * @param {Function} [cb] cb(err) - */ -Query.prototype.fetch = function(cb) { - cb = this.model.wrapCallback(cb); - this.model._context.fetchQuery(this); - - this.fetchCount++; - - if (!this.created) this.create(); - var query = this; - - var model = this.model; - var shareDocs = collectionShareDocs(this.model, this.collectionName); - var options = {docMode: 'fetch', knownDocs: shareDocs}; - if (this.source) options.source = this.source; - - model.root.shareConnection.createFetchQuery( - this.collectionName, this.sourceQuery(), options, fetchQueryCallback - ); - function fetchQueryCallback(err, results, extra) { - if (err) return cb(err); - var ids = resultsIds(results); - - // Keep track of the ids at fetch time for use in unfetch - query.fetchIds.push(ids.slice()); - // Update the results ids and extra - model._setDiff(query.idsSegments, ids); - if (extra !== void 0) { - model._setDiffDeep(query.extraSegments, extra); - } - - // Call fetchDoc for each document returned so that the proper load events - // and internal counts are maintained. However, specify that we already - // loaded the documents as part of the query, since we don't want to - // actually fetch the documents again - var alreadyLoaded = true; - for (var i = 0; i < ids.length; i++) { - model.fetchDoc(query.collectionName, ids[i], null, alreadyLoaded); - } - cb(); - } - return this; -}; - -/** - * Sets up a subscription to `this` query. - * @param {Function} cb(err) - */ -Query.prototype.subscribe = function(cb) { - cb = this.model.wrapCallback(cb); - this.model._context.subscribeQuery(this); - - var query = this; - - if (this.subscribeCount++) { - process.nextTick(function() { - var data = query.model._get(query.segments); - if (data) cb(); - else query._pendingSubscribeCallbacks.push(cb); - }); - return this; - } - - if (!this.created) this.create(); - - // When doing server-side rendering, we actually do a fetch the first time - // that subscribe is called, but keep track of the state as if subscribe - // were called for proper initialization in the client - var shareDocs = collectionShareDocs(this.model, this.collectionName); - var options = {docMode: 'sub', knownDocs: shareDocs}; - if (this.source) options.source = this.source; - - if (!this.model.root.fetchOnly) { - this._shareSubscribe(options, cb); - return this; - } - - var model = this.model; - options.docMode = 'fetch'; - model.root.shareConnection.createFetchQuery( - this.collectionName, this.sourceQuery(), options, function(err, results, extra) { - if (err) return cb(err); - var ids = resultsIds(results); - if (extra !== void 0) { - model._setDiffDeep(query.extraSegments, extra); - } - query._onChange(ids, null, cb); - while (cb = query._pendingSubscribeCallbacks.shift()) { - query._onChange(ids, null, cb); - } - } - ); - return this; -}; - -/** - * @private - * @param {Object} options - * @param {String} [options.source] - * @param {Boolean} [options.poll] - * @param {Boolean} [options.docMode = fetch or subscribe] - * @param {Function} cb(err, results) - */ -Query.prototype._shareSubscribe = function(options, cb) { - var query = this; - var model = this.model; - this.shareQuery = this.model.root.shareConnection.createSubscribeQuery( - this.collectionName, this.sourceQuery(), options, function(err, results, extra) { - if (err) return cb(err); - if (extra !== void 0) { - model._setDiffDeep(query.extraSegments, extra); - } - // Results are not set in the callback, because the shareQuery should - // emit a 'change' event before calling back - cb(); - } - ); - var query = this; - this.shareQuery.on('insert', function(shareDocs, index) { - query._onInsert(shareDocs, index); - }); - this.shareQuery.on('remove', function(shareDocs, index) { - query._onRemove(shareDocs, index); - }); - this.shareQuery.on('move', function(shareDocs, from, to) { - query._onMove(shareDocs, from, to); - }); - this.shareQuery.on('change', function(results, previous) { - // Get the new and previous list of ids when the entire results set changes - var ids = resultsIds(results); - var previousIds = previous && resultsIds(previous); - query._onChange(ids, previousIds); - }); - this.shareQuery.on('extra', function(extra) { - model._setDiffDeep(query.extraSegments, extra); - }); -}; - -/** - * @public - * @param {Function} cb(err, newFetchCount) - */ -Query.prototype.unfetch = function(cb) { - cb = this.model.wrapCallback(cb); - this.model._context.unfetchQuery(this); - - // No effect if the query is not currently fetched - if (!this.fetchCount) { - cb(); - return this; - } - - var ids = this.fetchIds.shift() || []; - for (var i = 0; i < ids.length; i++) { - this.model.unfetchDoc(this.collectionName, ids[i]); - } - - var query = this; - if (this.model.root.unloadDelay) { - setTimeout(finishUnfetchQuery, this.model.root.unloadDelay); - } else { - finishUnfetchQuery(); - } - function finishUnfetchQuery() { - var count = --query.fetchCount; - if (count) return cb(null, count); - // Cleanup when no fetches or subscribes remain - if (!query.subscribeCount) query.destroy(); - cb(null, 0); - } - return this; -}; - -Query.prototype.unsubscribe = function(cb) { - cb = this.model.wrapCallback(cb); - this.model._context.unsubscribeQuery(this); - - // No effect if the query is not currently subscribed - if (!this.subscribeCount) { - cb(); - return this; - } - - var query = this; - if (this.model.root.unloadDelay) { - setTimeout(finishUnsubscribeQuery, this.model.root.unloadDelay); - } else { - finishUnsubscribeQuery(); - } - function finishUnsubscribeQuery() { - var count = --query.subscribeCount; - if (count) return cb(null, count); - - var ids; - if (query.shareQuery) { - ids = resultsIds(query.shareQuery.results); - query.shareQuery.destroy(); - query.shareQuery = null; - } - - if (!query.model.root.fetchOnly && ids && ids.length) { - // Unsubscribe all documents that this query currently has in results - var group = util.asyncGroup(unsubscribeQueryCallback); - for (var i = 0; i < ids.length; i++) { - query.model.unsubscribeDoc(query.collectionName, ids[i], group()); - } - } - unsubscribeQueryCallback(); - } - function unsubscribeQueryCallback(err) { - if (err) return cb(err); - // Cleanup when no fetches or subscribes remain - if (!query.fetchCount) query.destroy(); - cb(null, 0); - } - return this; -}; - -Query.prototype._onInsert = function(shareDocs, index) { - var ids = []; - for (var i = 0; i < shareDocs.length; i++) { - var id = shareDocs[i].name; - ids.push(id); - this.model.subscribeDoc(this.collectionName, id); - } - this.model._insert(this.idsSegments, index, ids); -}; -Query.prototype._onRemove = function(shareDocs, index) { - this.model._remove(this.idsSegments, index, shareDocs.length); - for (var i = 0; i < shareDocs.length; i++) { - this.model.unsubscribeDoc(this.collectionName, shareDocs[i].name); - } -}; -Query.prototype._onMove = function(shareDocs, from, to) { - this.model._move(this.idsSegments, from, to, shareDocs.length); -}; - -Query.prototype._onChange = function(ids, previousIds, cb) { - // Diff the new and previous list of ids, subscribing to documents for - // inserted ids and unsubscribing from documents for removed ids - var diff = (previousIds) ? - arrayDiff(previousIds, ids) : - [new arrayDiff.InsertDiff(0, ids)]; - var previousCopy = previousIds && previousIds.slice(); - - // The results are updated via a different diff, since they might already - // have a value from a fetch or previous shareQuery instance - this.model._setDiff(this.idsSegments, ids); - - var group, finished; - if (cb) { - group = util.asyncGroup(cb); - finished = group(); - } - for (var i = 0; i < diff.length; i++) { - var item = diff[i]; - if (item instanceof arrayDiff.InsertDiff) { - // Subscribe to the document for each inserted id - var values = item.values; - for (var j = 0; j < values.length; j++) { - this.model.subscribeDoc(this.collectionName, values[j], cb && group()); - } - } else if (item instanceof arrayDiff.RemoveDiff) { - var values = previousCopy.splice(item.index, item.howMany); - // Unsubscribe from the document for each removed id - for (var j = 0; j < values.length; j++) { - this.model.unsubscribeDoc(this.collectionName, values[j], cb && group()); - } - } - // Moving doesn't change document subscriptions, so that is ignored. - } - // Make sure that the callback gets called if the diff is empty or it - // contains no inserts or removes - finished && finished(); -}; - -Query.prototype.get = function() { - var results = []; - var data = this.model._get(this.segments); - if (!data) { - console.warn('You must fetch or subscribe to a query before getting its results.'); - return results; - } - var ids = data.ids; - if (!ids) return results; - - var collection = this.model.getCollection(this.collectionName); - for (var i = 0, l = ids.length; i < l; i++) { - var id = ids[i]; - var doc = collection && collection.docs[id]; - results.push(doc && doc.get()); - } - return results; -}; - -Query.prototype.getIds = function() { - return this.model._get(this.idsSegments); -}; - -Query.prototype.getExtra = function() { - return this.model._get(this.extraSegments); -}; - -Query.prototype.ref = function(from) { - var idsPath = this.idsSegments.join('.'); - return this.model.refList(from, this.collectionName, idsPath); -}; - -Query.prototype.refIds = function(from) { - var idsPath = this.idsSegments.join('.'); - return this.model.root.ref(from, idsPath); -}; - -Query.prototype.refExtra = function(from, relPath) { - var extraPath = this.extraSegments.join('.'); - if (relPath) extraPath += '.' + relPath; - return this.model.root.ref(from, extraPath); -}; - -Query.prototype.serialize = function() { - var ids = this.getIds(); - var collection = this.model.getCollection(this.collectionName); - var snapshots, versions; - if (collection) { - snapshots = []; - versions = []; - for (var i = 0; i < ids.length; i++) { - var id = ids[i]; - var doc = collection.docs[id]; - if (doc) { - snapshots.push(doc.shareDoc.snapshot); - versions.push(doc.shareDoc.version); - collection.remove(id); - } else { - snapshots.push(0); - versions.push(0); - } - } - } - var counts = []; - var contexts = this.model.root._contexts; - for (var key in contexts) { - var context = contexts[key]; - var subscribed = context.subscribedQueries[this.hash] || 0; - var fetched = context.fetchedQueries[this.hash] || 0; - if (subscribed || fetched) { - if (key !== 'root') { - counts.push([subscribed, fetched, key]); - } else if (fetched) { - counts.push([subscribed, fetched]); - } else { - counts.push([subscribed]); - } - } - } - var serialized = [ - counts - , this.collectionName - , this.expression - , ids - , snapshots - , versions - , this.source - , this.getExtra() - ]; - while (serialized[serialized.length - 1] == null) { - serialized.pop(); - } - return serialized; -}; - -function queryHash(collectionName, expression, source) { - var args = [collectionName, expression, source]; - return JSON.stringify(args).replace(/\./g, '|'); -} - -function resultsIds(results) { - var ids = []; - for (var i = 0; i < results.length; i++) { - var shareDoc = results[i]; - ids.push(shareDoc.name); - } - return ids; -} - -function pathIds(model, segments) { - var value = model._get(segments); - return (typeof value === 'string') ? [value] : - (Array.isArray(value)) ? value.slice() : []; -} - -function collectionShareDocs(model, collectionName) { - var collection = model.getCollection(collectionName); - if (!collection) return; - - var results = []; - for (var name in collection.docs) { - results.push(collection.docs[name].shareDoc); - } - - return results; -} diff --git a/lib/Model/RemoteDoc.js b/lib/Model/RemoteDoc.js deleted file mode 100644 index 4cf9e626a..000000000 --- a/lib/Model/RemoteDoc.js +++ /dev/null @@ -1,441 +0,0 @@ -/** - * RemoteDoc adapts the ShareJS operation protocol to Racer's mutator - * interface. - * - * 1. It maps Racer's mutator methods to outgoing ShareJS operations. - * 2. It maps incoming ShareJS operations to Racer events. - */ - -var Doc = require('./Doc'); -var util = require('../util'); - -module.exports = RemoteDoc; - -function RemoteDoc(model, collectionName, id, data) { - Doc.call(this, model, collectionName, id); - var shareDoc = this.shareDoc = model._getOrCreateShareDoc(collectionName, id, data); - this.createdLocally = false; - this.model = model = model.pass({$remote: true}); - this._updateCollectionData(); - - var doc = this; - shareDoc.on('op', function(op, isLocal) { - // Don't emit on local operations, since they are emitted in the mutator - if (isLocal) return; - doc._updateCollectionData(); - doc._onOp(op); - }); - shareDoc.on('del', function(isLocal, previous) { - // Calling the shareDoc.del method does not emit an operation event, - // so we create the appropriate event here. - if (isLocal) return; - delete doc.collectionData[id]; - model.emit('change', [collectionName, id], [void 0, previous, model._pass]); - }); - shareDoc.on('create', function(isLocal) { - // Local creates should not emit an event, since they only happen - // implicitly as a result of another mutation, and that operation will - // emit the appropriate event. Remote creates can set the snapshot data - // without emitting an operation event, so an event needs to be emitted - // for them. - if (isLocal) { - // Track when a document was created by this client, so that we don't - // emit a load event when subsequently subscribed - doc.createdLocally = true; - return; - } - doc._updateCollectionData(); - var value = shareDoc.snapshot; - model.emit('change', [collectionName, id], [value, void 0, model._pass]); - }); -} - -RemoteDoc.prototype = new Doc(); - -RemoteDoc.prototype._updateCollectionData = function() { - var snapshot = this.shareDoc.snapshot; - if (typeof snapshot === 'object' && !Array.isArray(snapshot) && snapshot !== null) { - snapshot.id = this.id; - } - this.collectionData[this.id] = snapshot; -}; - -RemoteDoc.prototype.set = function(segments, value, cb) { - if (segments.length === 0 && !this.shareDoc.type) { - // We copy the snapshot at time of create to prevent the id added outside - // of ShareJS from getting stored in the data - var snapshot = util.copy(value); - if (snapshot) delete snapshot.id; - this.shareDoc.create('json0', snapshot, cb); - // The id value will get added to the snapshot that was passed in - this.shareDoc.snapshot = value; - this._updateCollectionData(); - return; - } - var previous = this._createImplied(segments); - var lastSegment = segments[segments.length - 1]; - if (previous instanceof ImpliedOp) { - previous.value[lastSegment] = value; - this.shareDoc.submitOp(previous.op, cb); - this._updateCollectionData(); - return; - } - var op = (util.isArrayIndex(lastSegment)) ? - [new ListReplaceOp(segments.slice(0, -1), lastSegment, previous, value)] : - [new ObjectReplaceOp(segments, previous, value)]; - this.shareDoc.submitOp(op, cb); - this._updateCollectionData(); - return previous; -}; - -RemoteDoc.prototype.del = function(segments, cb) { - if (segments.length === 0) { - var previous = this.get(); - this.shareDoc.del(cb); - delete this.collectionData[this.id]; - return previous; - } - // Don't do anything if the value is already undefined, since - // the del method should not create anything - var previous = this.get(segments); - if (previous === void 0) { - cb(); - return; - } - var op = [new ObjectDeleteOp(segments, previous)]; - this.shareDoc.submitOp(op, cb); - this._updateCollectionData(); - return previous; -}; - -RemoteDoc.prototype.increment = function(segments, byNumber, cb) { - var previous = this._createImplied(segments); - if (previous instanceof ImpliedOp) { - var lastSegment = segments[segments.length - 1]; - previous.value[lastSegment] = byNumber; - this.shareDoc.submitOp(previous.op, cb); - this._updateCollectionData(); - return byNumber; - } - if (previous == null) { - var lastSegment = segments[segments.length - 1]; - var op = (util.isArrayIndex(lastSegment)) ? - [new ListInsertOp(segments.slice(0, -1), lastSegment, byNumber)] : - [new ObjectInsertOp(segments, byNumber)]; - this.shareDoc.submitOp(op, cb); - this._updateCollectionData(); - return byNumber; - } - var op = [new IncrementOp(segments, byNumber)]; - this.shareDoc.submitOp(op, cb); - this._updateCollectionData(); - return previous + byNumber; -}; - -RemoteDoc.prototype.push = function(segments, value, cb) { - var shareDoc = this.shareDoc; - function push(arr, fnCb) { - var op = [new ListInsertOp(segments, arr.length, value)]; - shareDoc.submitOp(op, fnCb); - return arr.length; - } - return this._arrayApply(segments, push, cb); -}; - -RemoteDoc.prototype.unshift = function(segments, value, cb) { - var shareDoc = this.shareDoc; - function unshift(arr, fnCb) { - var op = [new ListInsertOp(segments, 0, value)]; - shareDoc.submitOp(op, fnCb); - return arr.length; - } - return this._arrayApply(segments, unshift, cb); -}; - -RemoteDoc.prototype.insert = function(segments, index, values, cb) { - var shareDoc = this.shareDoc; - function insert(arr, fnCb) { - var op = createInsertOp(segments, index, values); - shareDoc.submitOp(op, fnCb); - return arr.length; - } - return this._arrayApply(segments, insert, cb); -}; - -function createInsertOp(segments, index, values) { - if (!Array.isArray(values)) { - return [new ListInsertOp(segments, index, values)]; - } - var op = []; - for (var i = 0, len = values.length; i < len; i++) { - op.push(new ListInsertOp(segments, index++, values[i])); - } - return op; -} - -RemoteDoc.prototype.pop = function(segments, cb) { - var shareDoc = this.shareDoc; - function pop(arr, fnCb) { - var index = arr.length - 1; - var value = arr[index]; - var op = [new ListRemoveOp(segments, index, value)]; - shareDoc.submitOp(op, fnCb); - return value; - } - return this._arrayApply(segments, pop, cb); -}; - -RemoteDoc.prototype.shift = function(segments, cb) { - var shareDoc = this.shareDoc; - function shift(arr, fnCb) { - var value = arr[0]; - var op = [new ListRemoveOp(segments, 0, value)]; - shareDoc.submitOp(op, fnCb); - return value; - } - return this._arrayApply(segments, shift, cb); -}; - -RemoteDoc.prototype.remove = function(segments, index, howMany, cb) { - var shareDoc = this.shareDoc; - function remove(arr, fnCb) { - var values = arr.slice(index, index + howMany); - var op = []; - for (var i = 0, len = values.length; i < len; i++) { - op.push(new ListRemoveOp(segments, index, values[i])); - } - shareDoc.submitOp(op, fnCb); - return values; - } - return this._arrayApply(segments, remove, cb); -}; - -RemoteDoc.prototype.move = function(segments, from, to, howMany, cb) { - var shareDoc = this.shareDoc; - function move(arr, fnCb) { - // Get the return value - var values = arr.slice(from, from + howMany); - - // Build an op that moves each item individually - var op = []; - for (var i = 0; i < howMany; i++) { - op.push(new ListMoveOp(segments, (from < to) ? from : from + howMany - 1, (from < to) ? to + howMany - 1 : to)); - } - shareDoc.submitOp(op, fnCb); - - return values; - } - return this._arrayApply(segments, move, cb); -}; - -RemoteDoc.prototype.stringInsert = function(segments, index, value, cb) { - var previous = this._createImplied(segments); - if (previous instanceof ImpliedOp) { - var lastSegment = segments[segments.length - 1]; - previous.value[lastSegment] = value; - this.shareDoc.submitOp(previous.op, cb); - this._updateCollectionData(); - return; - } - if (previous == null) { - var lastSegment = segments[segments.length - 1]; - var op = (util.isArrayIndex(lastSegment)) ? - [new ListInsertOp(segments.slice(0, -1), lastSegment, value)] : - [new ObjectInsertOp(segments, value)]; - this.shareDoc.submitOp(op, cb); - this._updateCollectionData(); - return previous; - } - var op = [new StringInsertOp(segments, index, value)]; - this.shareDoc.submitOp(op, cb); - this._updateCollectionData(); - return previous; -}; - -RemoteDoc.prototype.stringRemove = function(segments, index, howMany, cb) { - var previous = this._createImplied(segments); - if (previous instanceof ImpliedOp) return; - if (previous == null) return previous; - var removed = previous.slice(index, index + howMany); - var op = [new StringRemoveOp(segments, index, removed)]; - this.shareDoc.submitOp(op, cb); - this._updateCollectionData(); - return previous; -}; - -RemoteDoc.prototype.get = function(segments) { - return util.lookup(segments, this.shareDoc.snapshot); -}; - -RemoteDoc.prototype._createImplied = function(segments) { - if (!this.shareDoc.type) { - this.shareDoc.create('json0'); - } - var parent = this.shareDoc; - var key = 'snapshot'; - var node = parent[key]; - var i = 0; - var nextKey = segments[i++]; - var op, value; - while (nextKey != null) { - if (!node) { - if (op) { - value = value[key] = util.isArrayIndex(nextKey) ? [] : {}; - } else { - value = util.isArrayIndex(nextKey) ? [] : {}; - op = (Array.isArray(parent)) ? - new ListInsertOp(segments.slice(0, i - 2), key, value) : - new ObjectInsertOp(segments.slice(0, i - 1), value); - } - node = value; - } - parent = node; - key = nextKey; - node = parent[key]; - nextKey = segments[i++]; - } - if (op) return new ImpliedOp(op, value); - return node; -}; - -function ImpliedOp(op, value) { - this.op = op; - this.value = value; -} - -RemoteDoc.prototype._arrayApply = function(segments, fn, cb) { - var arr = this._createImplied(segments); - if (arr instanceof ImpliedOp) { - this.shareDoc.submitOp(arr.op); - arr = this.get(segments); - } - if (arr == null) { - var lastSegment = segments[segments.length - 1]; - var op = (util.isArrayIndex(lastSegment)) ? - [new ListInsertOp(segments.slice(0, -1), lastSegment, [])] : - [new ObjectInsertOp(segments, [])]; - this.shareDoc.submitOp(op); - arr = this.get(segments); - } - - if (!Array.isArray(arr)) { - var message = this._errorMessage(fn.name + ' on non-array', segments, arr); - var err = new TypeError(message); - return cb(err); - } - var out = fn(arr, cb); - this._updateCollectionData(); - return out; -}; - -RemoteDoc.prototype._onOp = function(op) { - var item = op[0]; - var segments = [this.collectionName, this.id].concat(item.p); - var model = this.model; - - // ObjectReplaceOp, ObjectInsertOp, or ObjectDeleteOp - if (defined(item.oi) || defined(item.od)) { - var value = item.oi; - var previous = item.od; - model.emit('change', segments, [value, previous, model._pass]); - - // ListReplaceOp - } else if (defined(item.li) && defined(item.ld)) { - var value = item.li; - var previous = item.ld; - model.emit('change', segments, [value, previous, model._pass]); - - // ListInsertOp - } else if (defined(item.li)) { - var index = segments[segments.length - 1]; - var values = [item.li]; - model.emit('insert', segments.slice(0, -1), [index, values, model._pass]); - - // ListRemoveOp - } else if (defined(item.ld)) { - var index = segments[segments.length - 1]; - var removed = [item.ld]; - model.emit('remove', segments.slice(0, -1), [index, removed, model._pass]); - - // ListMoveOp - } else if (defined(item.lm)) { - var from = segments[segments.length - 1]; - var to = item.lm; - var howMany = 1; - model.emit('move', segments.slice(0, -1), [from, to, howMany, model._pass]); - - // StringInsertOp - } else if (defined(item.si)) { - var index = segments[segments.length - 1]; - var text = item.si; - segments = segments.slice(0, -1); - var value = model._get(segments); - var previous = value.slice(0, index) + value.slice(index + text.length); - var pass = model.pass({$type: 'stringInsert', index: index, text: text})._pass; - model.emit('change', segments, [value, previous, pass]); - - // StringRemoveOp - } else if (defined(item.sd)) { - var index = segments[segments.length - 1]; - var text = item.sd; - var howMany = text.length; - segments = segments.slice(0, -1); - var value = model._get(segments); - var previous = value.slice(0, index) + text + value.slice(index); - var pass = model.pass({$type: 'stringRemove', index: index, howMany: howMany})._pass; - model.emit('change', segments, [value, previous, pass]); - - // IncrementOp - } else if (defined(item.na)) { - var value = this.get(item.p); - var previous = value - item.na; - model.emit('change', segments, [value, previous, model._pass]); - } -}; - -function ObjectReplaceOp(segments, before, after) { - this.p = util.castSegments(segments); - this.od = before; - this.oi = (after === void 0) ? null : after; -} -function ObjectInsertOp(segments, value) { - this.p = util.castSegments(segments); - this.oi = (value === void 0) ? null : value; -} -function ObjectDeleteOp(segments, value) { - this.p = util.castSegments(segments); - this.od = (value === void 0) ? null : value; -} -function ListReplaceOp(segments, index, before, after) { - this.p = util.castSegments(segments.concat(index)); - this.ld = before; - this.li = (after === void 0) ? null : after; -} -function ListInsertOp(segments, index, value) { - this.p = util.castSegments(segments.concat(index)); - this.li = (value === void 0) ? null : value; -} -function ListRemoveOp(segments, index, value) { - this.p = util.castSegments(segments.concat(index)); - this.ld = (value === void 0) ? null : value; -} -function ListMoveOp(segments, from, to) { - this.p = util.castSegments(segments.concat(from)); - this.lm = to; -} -function StringInsertOp(segments, index, value) { - this.p = util.castSegments(segments.concat(index)); - this.si = value; -} -function StringRemoveOp(segments, index, value) { - this.p = util.castSegments(segments.concat(index)); - this.sd = value; -} -function IncrementOp(segments, byNumber) { - this.p = util.castSegments(segments); - this.na = byNumber; -} - -function defined(value) { - return value !== void 0; -} diff --git a/lib/Model/bundle.js b/lib/Model/bundle.js deleted file mode 100644 index f7aad0462..000000000 --- a/lib/Model/bundle.js +++ /dev/null @@ -1,89 +0,0 @@ -var Model = require('./Model'); - -Model.BUNDLE_TIMEOUT = 10 * 1000; - -Model.INITS.push(function(model, options) { - model.root.bundleTimeout = options.bundleTimeout || Model.BUNDLE_TIMEOUT; -}); - -Model.prototype.bundle = function(cb) { - var root = this.root; - var timeout = setTimeout(function() { - var message = 'Model bundle took longer than ' + root.bundleTimeout + 'ms'; - var err = new Error(message); - cb(err); - // Keep the callback from being called more than once - cb = function() {}; - }, root.bundleTimeout); - - root.whenNothingPending(function finishBundle() { - clearTimeout(timeout); - stripIds(root); - var bundle = { - queries: root._queries.toJSON() - , contexts: root._contexts - , refs: root._refs.toJSON() - , refLists: root._refLists.toJSON() - , fns: root._fns.toJSON() - , filters: root._filters.toJSON() - , nodeEnv: process.env.NODE_ENV - }; - stripComputed(root); - bundle.collections = serializeCollections(root); - root.emit('bundle', bundle); - root._commit = errorOnCommit; - cb(null, bundle); - }); -}; - -function stripIds(root) { - // Strip ids from remote documents, which get added automatically. Don't do - // this for local documents, since they are often not traditional object - // documents with ids and it doesn't make sense to add ids to them always - for (var collectionName in root.data) { - if (root._isLocal(collectionName)) continue; - var collectionData = root.data[collectionName]; - for (var id in collectionData) { - var docData = collectionData[id]; - if (docData) delete docData.id; - } - } -} - -function stripComputed(root) { - var silentModel = root.silent(); - var refListsMap = root._refLists.fromMap; - var fnsMap = root._fns.fromMap; - for (var from in refListsMap) { - silentModel._del(refListsMap[from].fromSegments); - } - for (var from in fnsMap) { - silentModel._del(fnsMap[from].fromSegments); - } - silentModel.removeAllFilters(); - silentModel.destroy('$queries'); -} - -function serializeCollections(root) { - var out = {}; - for (var collectionName in root.collections) { - var collection = root.collections[collectionName]; - out[collectionName] = {}; - for (var id in collection.docs) { - var doc = collection.docs[id]; - var shareDoc = doc.shareDoc; - out[collectionName][id] = (shareDoc) ? - { - v: shareDoc.version - , data: shareDoc.snapshot - , type: shareDoc.type && shareDoc.type.name - } : - doc.snapshot; - } - } - return out; -} - -function errorOnCommit() { - this.emit('error', new Error('Model mutation performed after bundling')); -} diff --git a/lib/Model/collections.js b/lib/Model/collections.js deleted file mode 100644 index 85583c1a5..000000000 --- a/lib/Model/collections.js +++ /dev/null @@ -1,149 +0,0 @@ -var Model = require('./Model'); -var LocalDoc = require('./LocalDoc'); -var util = require('../util'); - -function CollectionMap() {} -function ModelData() {} -function DocMap() {} -function CollectionData() {} - -Model.INITS.push(function(model) { - model.root.collections = new CollectionMap(); - model.root.data = new ModelData(); -}); - -Model.prototype.getCollection = function(collectionName) { - return this.root.collections[collectionName]; -}; -Model.prototype.getDoc = function(collectionName, id) { - var collection = this.root.collections[collectionName]; - return collection && collection.docs[id]; -}; -Model.prototype.get = function(subpath) { - var segments = this._splitPath(subpath); - return this._get(segments); -}; -Model.prototype._get = function(segments) { - return util.lookup(segments, this.root.data); -}; -Model.prototype.getCopy = function(subpath) { - var segments = this._splitPath(subpath); - return this._getCopy(segments); -}; -Model.prototype._getCopy = function(segments) { - var value = this._get(segments); - return util.copy(value); -}; -Model.prototype.getDeepCopy = function(subpath) { - var segments = this._splitPath(subpath); - return this._getDeepCopy(segments); -}; -Model.prototype._getDeepCopy = function(segments) { - var value = this._get(segments); - return util.deepCopy(value); -}; -Model.prototype.getOrCreateCollection = function(name) { - var collection = this.root.collections[name]; - if (collection) return collection; - var Doc = this._getDocConstructor(name); - collection = new Collection(this.root, name, Doc); - this.root.collections[name] = collection; - return collection; -}; -Model.prototype._getDocConstructor = function() { - // Only create local documents. This is overriden in ./connection.js, so that - // the RemoteDoc behavior can be selectively included - return LocalDoc; -}; - -/** - * Returns an existing document with id in a collection. If the document does - * not exist, then creates the document with id in a collection and returns the - * new document. - * @param {String} collectionName - * @param {String} id - * @param {Object} [data] data to create if doc with id does not exist in collection - */ -Model.prototype.getOrCreateDoc = function(collectionName, id, data) { - var collection = this.getOrCreateCollection(collectionName); - return collection.docs[id] || collection.add(id, data); -}; - -/** - * @param {String} subpath - */ -Model.prototype.destroy = function(subpath) { - var segments = this._splitPath(subpath); - // Silently remove all types of listeners within subpath - var silentModel = this.silent(); - silentModel.removeAllListeners(null, subpath); - silentModel._removeAllRefs(segments); - silentModel._stopAll(segments); - silentModel._removeAllFilters(segments); - // Silently remove all model data within subpath - if (segments.length === 0) { - this.root.collections = new CollectionMap(); - // Delete each property of data instead of creating a new object so that - // it is possible to continue using a reference to the original data object - var data = this.root.data; - for (var key in data) { - delete data[key]; - } - } else if (segments.length === 1) { - var collection = this.getCollection(segments[0]); - collection && collection.destroy(); - } else { - silentModel._del(segments); - } -}; - -function Collection(model, name, Doc) { - this.model = model; - this.name = name; - this.Doc = Doc; - this.docs = new DocMap(); - this.data = model.data[name] = new CollectionData(); -} - -/** - * Adds a document with `id` and `data` to `this` Collection. - * @param {String} id - * @param {Object} data - * @return {LocalDoc|RemoteDoc} doc - */ -Collection.prototype.add = function(id, data) { - var doc = new this.Doc(this.model, this.name, id, data); - this.docs[id] = doc; - return doc; -}; -Collection.prototype.destroy = function() { - delete this.model.collections[this.name]; - delete this.model.data[this.name]; -}; - -/** - * Removes the document with `id` from `this` Collection. If there are no more - * documents in the Collection after the given document is removed, then this - * also destroys the Collection. - * @param {String} id - */ -Collection.prototype.remove = function(id) { - delete this.docs[id]; - delete this.data[id]; - if (noKeys(this.docs)) this.destroy(); -}; - -/** - * Returns an object that maps doc ids to fully resolved documents. - * @return {Object} - */ -Collection.prototype.get = function() { - return this.data; -}; - -function noKeys(object) { - for (var key in object) { - return false; - } - return true; -} diff --git a/lib/Model/connection.js b/lib/Model/connection.js deleted file mode 100644 index 6fc1f5438..000000000 --- a/lib/Model/connection.js +++ /dev/null @@ -1,125 +0,0 @@ -var share = require('share/lib/client'); -var Channel = require('../Channel'); -var Model = require('./Model'); -var LocalDoc = require('./LocalDoc'); -var RemoteDoc = require('./RemoteDoc'); - -Model.prototype.createConnection = function(bundle) { - // Model::_createSocket should be defined by the socket plugin - this.root.socket = this._createSocket(bundle); - - // The Share connection will bind to the socket by defining the onopen, - // onmessage, etc. methods - var shareConnection = this.root.shareConnection = new share.Connection(this.root.socket); - var segments = ['$connection', 'state']; - var states = ['connecting', 'connected', 'disconnected', 'stopped']; - var model = this; - states.forEach(function(state) { - shareConnection.on(state, function() { - model._setDiff(segments, state); - }); - }); - this._set(segments, 'connected'); - - // Wrap the socket methods on top of Share's methods - this._createChannel(); -}; - -Model.prototype.connect = function() { - this.root.socket.open(); -}; -Model.prototype.disconnect = function() { - this.root.socket.close(); -}; -Model.prototype.reconnect = function() { - this.disconnect(); - this.connect(); -}; -// Clean delayed disconnect -Model.prototype.close = function(cb) { - cb = this.wrapCallback(cb); - var model = this; - this.whenNothingPending(function() { - model.root.socket.close(); - cb(); - }); -}; - -Model.prototype._createChannel = function() { - this.root.channel = new Channel(this.root.socket); -}; - -Model.prototype._getOrCreateShareDoc = function(collectionName, id, data) { - var shareDoc = this.root.shareConnection.get(collectionName, id, data); - shareDoc.incremental = true; - return shareDoc; -}; - -Model.prototype._isLocal = function(name) { - // Whether the collection is local or remote is determined by its name. - // Collections starting with an underscore ('_') are for user-defined local - // collections, those starting with a dollar sign ('$'') are for - // framework-defined local collections, and all others are remote. - var firstCharcter = name.charAt(0); - return firstCharcter === '_' || firstCharcter === '$'; -}; - -Model.prototype._getDocConstructor = function(name) { - return (this._isLocal(name)) ? LocalDoc : RemoteDoc; -}; - -Model.prototype.hasPending = function() { - return !!this._firstPendingDoc(); -}; - -Model.prototype.hasWritePending = function() { - return !!this._firstWritePendingDoc(); -}; - -Model.prototype.whenNothingPending = function(cb) { - var doc = this._firstPendingDoc(); - if (doc) { - // If a document is found with a pending operation, wait for it to emit - // that nothing is pending anymore, and then recheck all documents again. - // We have to recheck all documents, just in case another mutation has - // been made in the meantime as a result of an event callback - var model = this; - doc.shareDoc.once('nothing pending', function retryNothingPending() { - process.nextTick(function(){ - model.whenNothingPending(cb); - }); - }); - return; - } - // Call back when no Share documents have pending operations - process.nextTick(cb); -}; - -Model.prototype._firstPendingDoc = function() { - return this._firstShareDoc(hasPending); -}; -Model.prototype._firstWritePendingDoc = function() { - return this._firstShareDoc(hasWritePending); -}; - -function hasPending(shareDoc) { - return shareDoc.hasPending(); -} -function hasWritePending(shareDoc) { - return shareDoc.inflightData != null || !!shareDoc.pendingData.length; -} - -Model.prototype._firstShareDoc = function(fn) { - // Loop through all of this model's documents, and return the first document - // encountered with that matches the provided test function - var collections = this.root.collections; - for (var collectionName in collections) { - var collection = collections[collectionName]; - for (var id in collection.docs) { - var doc = collection.docs[id]; - if (doc.shareDoc && fn(doc.shareDoc)) { - return doc; - } - } - } -}; diff --git a/lib/Model/connection.server.js b/lib/Model/connection.server.js deleted file mode 100644 index b6034a3f7..000000000 --- a/lib/Model/connection.server.js +++ /dev/null @@ -1,46 +0,0 @@ -var share = require('share'); -var Model = require('./Model'); - -Model.prototype.createConnection = function(stream, logger) { - var socket = new StreamSocket(stream, logger); - this.root.socket = socket; - this.root.shareConnection = new share.client.Connection(socket); - socket.onopen(); - this._set(['$connection', 'state'], 'connected'); - this._createChannel(); -}; - -/** - * Wrapper to make a stream look like a BrowserChannel socket - * @param {Stream} stream - */ -function StreamSocket(stream, logger) { - this.stream = stream; - this.logger = logger; - var socket = this; - stream._read = function _read() {}; - stream._write = function _write(chunk, encoding, callback) { - socket.onmessage({ - type: 'message', - data: chunk - }); - if (logger) logger.write(chunk); - callback(); - }; -} -StreamSocket.prototype.send = function(data) { - var copy = JSON.parse(JSON.stringify(data)); - this.stream.push(copy); - if (this.logger) this.logger.write(copy); -}; -StreamSocket.prototype.close = function() { - this.stream.end(); - this.stream.emit('close'); - this.stream.emit('end'); - this.onclose(); -}; -StreamSocket.prototype.onmessage = function() {}; -StreamSocket.prototype.onclose = function() {}; -StreamSocket.prototype.onerror = function() {}; -StreamSocket.prototype.onopen = function() {}; -StreamSocket.prototype.onconnecting = function() {}; diff --git a/lib/Model/contexts.js b/lib/Model/contexts.js deleted file mode 100644 index 008ff2003..000000000 --- a/lib/Model/contexts.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Contexts are useful for keeping track of the origin of subscribes. - */ - -var Model = require('./Model'); -var Query = require('./Query'); - -Model.INITS.push(function(model) { - model.root._contexts = new Contexts(); - model.root.setContext('root'); -}); - -Model.prototype.context = function(id) { - var model = this._child(); - model.setContext(id); - return model; -}; - -Model.prototype.setContext = function(id) { - this._context = this.getOrCreateContext(id); -}; - -Model.prototype.getOrCreateContext = function(id) { - return this.root._contexts[id] || - (this.root._contexts[id] = new Context(this, id)); -}; - -Model.prototype.unload = function(id) { - var context = (id) ? this.root._contexts[id] : this._context; - context && context.unload(); -}; - -Model.prototype.unloadAll = function() { - var contexts = this.root._contexts; - for (var key in contexts) { - contexts[key].unload(); - } -}; - -function Contexts() {} - -function FetchedDocs() {} -function SubscribedDocs() {} -function FetchedQueries() {} -function SubscribedQueries() {} - -function Context(model, id) { - this.model = model; - this.id = id; - this.fetchedDocs = new FetchedDocs(); - this.subscribedDocs = new SubscribedDocs(); - this.fetchedQueries = new FetchedQueries(); - this.subscribedQueries = new SubscribedQueries(); -} - -Context.prototype.toJSON = function() { - return { - fetchedDocs: this.fetchedDocs - , subscribedDocs: this.subscribedDocs - }; -}; - -Context.prototype.fetchDoc = function(path, pass) { - if (pass.$query) return; - mapIncrement(this.fetchedDocs, path); -}; -Context.prototype.subscribeDoc = function(path, pass) { - if (pass.$query) return; - mapIncrement(this.subscribedDocs, path); -}; -Context.prototype.unfetchDoc = function(path, pass) { - if (pass.$query) return; - mapDecrement(this.fetchedDocs, path); -}; -Context.prototype.unsubscribeDoc = function(path, pass) { - if (pass.$query) return; - mapDecrement(this.subscribedDocs, path); -}; -Context.prototype.fetchQuery = function(query) { - mapIncrement(this.fetchedQueries, query.hash); -}; -Context.prototype.subscribeQuery = function(query) { - mapIncrement(this.subscribedQueries, query.hash); -}; -Context.prototype.unfetchQuery = function(query) { - mapDecrement(this.fetchedQueries, query.hash); -}; -Context.prototype.unsubscribeQuery = function(query) { - mapDecrement(this.subscribedQueries, query.hash); -}; -function mapIncrement(map, key) { - map[key] = (map[key] || 0) + 1; -} -function mapDecrement(map, key) { - map[key] && map[key]--; - if (!map[key]) delete map[key]; -} - -Context.prototype.unload = function() { - var model = this.model; - for (var hash in this.fetchedQueries) { - var query = model.root._queries.map[hash]; - if (!query) continue; - var count = this.fetchedQueries[hash]; - while (count--) query.unfetch(); - } - for (var hash in this.subscribedQueries) { - var query = model.root._queries.map[hash]; - if (!query) continue; - var count = this.subscribedQueries[hash]; - while (count--) query.unsubscribe(); - } - for (var path in this.fetchedDocs) { - var segments = path.split('.'); - var count = this.fetchedDocs[path]; - while (count--) model.unfetchDoc(segments[0], segments[1]); - } - for (var path in this.subscribedDocs) { - var segments = path.split('.'); - var count = this.subscribedDocs[path]; - while (count--) model.unsubscribeDoc(segments[0], segments[1]); - } -}; diff --git a/lib/Model/events.js b/lib/Model/events.js deleted file mode 100644 index c911e331e..000000000 --- a/lib/Model/events.js +++ /dev/null @@ -1,294 +0,0 @@ -var EventEmitter = require('events').EventEmitter; -var util = require('../util'); -var Model = require('./Model'); - -// This map determines which events get re-emitted as an 'all' event -Model.MUTATOR_EVENTS = { - change: true -, insert: true -, remove: true -, move: true -, stringInsert: true -, stringRemove: true -, load: true -, unload: true -}; - -Model.INITS.push(function(model) { - EventEmitter.call(this); - - // Set max listeners to unlimited - model.setMaxListeners(0); - - // Used in async methods to emit an error event if a callback is not supplied. - // This will throw if there is no handler for model.on('error') - model.root._defaultCallback = defaultCallback; - function defaultCallback(err) { - if (typeof err === 'string') err = new Error(err); - if (err) model.emit('error', err); - } - - model.root._mutatorEventQueue = null; - model.root._pass = new Passed({}, {}); - model.root._silent = null; - model.root._eventContext = null; -}); - -util.mergeInto(Model.prototype, EventEmitter.prototype); - -Model.prototype.wrapCallback = function(cb) { - if (!cb) return this.root._defaultCallback; - var model = this; - return function wrappedCallback() { - try { - return cb.apply(this, arguments); - } catch (err) { - model.emit('error', err); - } - }; -}; - -// EventEmitter.prototype.on, EventEmitter.prototype.addListener, and -// EventEmitter.prototype.once return `this`. The Model equivalents return -// the listener instead, since it is made internally for method subscriptions -// and may need to be passed to removeListener. - -Model.prototype._emit = EventEmitter.prototype.emit; -Model.prototype.emit = function(type) { - if (type === 'error') { - return this._emit.apply(this, arguments); - } - if (Model.MUTATOR_EVENTS[type]) { - if (this._silent) return this; - var segments = arguments[1]; - var eventArgs = arguments[2]; - if (this.root._mutatorEventQueue) { - this.root._mutatorEventQueue.push([type, segments, eventArgs]); - return this; - } - this.root._mutatorEventQueue = []; - this._emit(type, segments, eventArgs); - this._emit('all', segments, [type].concat(eventArgs)); - while (this.root._mutatorEventQueue.length) { - var queued = this.root._mutatorEventQueue.shift(); - type = queued[0]; - segments = queued[1]; - eventArgs = queued[2]; - this._emit(type, segments, eventArgs); - this._emit('all', segments, [type].concat(eventArgs)); - } - this.root._mutatorEventQueue = null; - return this; - } - return this._emit.apply(this, arguments); -}; - -Model.prototype._on = EventEmitter.prototype.on; -Model.prototype.addListener = -Model.prototype.on = function(type, pattern, cb) { - var listener = eventListener(this, pattern, cb); - this._on(type, listener); - return listener; -}; - -Model.prototype.once = function(type, pattern, cb) { - var listener = eventListener(this, pattern, cb); - function g() { - var matches = listener.apply(null, arguments); - if (matches) this.removeListener(type, g); - } - this._on(type, g); - return g; -}; - -Model.prototype._removeAllListeners = EventEmitter.prototype.removeAllListeners; -Model.prototype.removeAllListeners = function(type, subpattern) { - // If a pattern is specified without an event type, remove all model event - // listeners under that pattern for all events - if (!type) { - for (var key in this._events) { - this.removeAllListeners(key, subpattern); - } - return this; - } - - var pattern = this.path(subpattern); - // If no pattern is specified, remove all listeners like normal - if (!pattern) { - if (arguments.length === 0) { - return this._removeAllListeners(); - } else { - return this._removeAllListeners(type); - } - } - - // Remove all listeners for an event under a pattern - var listeners = this.listeners(type); - var segments = pattern.split('.'); - // Make sure to iterate in reverse, since the array might be - // mutated as listeners are removed - for (var i = listeners.length; i--;) { - var listener = listeners[i]; - if (patternContained(pattern, segments, listener)) { - this.removeListener(type, listener); - } - } - return this; -}; - -function patternContained(pattern, segments, listener) { - var listenerSegments = listener.patternSegments; - if (!listenerSegments) return false; - if (pattern === listener.pattern || pattern === '**') return true; - var len = segments.length; - if (len > listenerSegments.length) return false; - for (var i = 0; i < len; i++) { - if (segments[i] !== listenerSegments[i]) return false; - } - return true; -} - -Model.prototype.pass = function(object, invert) { - var model = this._child(); - model._pass = (invert) ? - new Passed(object, this._pass) : - new Passed(this._pass, object); - return model; -}; - -function Passed(previous, value) { - for (var key in previous) { - this[key] = previous[key]; - } - for (var key in value) { - this[key] = value[key]; - } -} - -/** - * The returned Model will or won't trigger event handlers when the model emits - * events, depending on `value` - * @param {Boolean|Null} value defaults to true - * @return {Model} - */ -Model.prototype.silent = function(value) { - var model = this._child(); - model._silent = (value == null) ? true : value; - return model; -}; - -Model.prototype.eventContext = function(value) { - var model = this._child(); - model._eventContext = value; - return model; -}; - -Model.prototype.removeContextListeners = function(value) { - if (arguments.length === 0) { - value = this._eventContext; - } - // Remove all events created within a given context - for (var type in this._events) { - var listeners = this.listeners(type); - // Make sure to iterate in reverse, since the array might be - // mutated as listeners are removed - for (var i = listeners.length; i--;) { - var listener = listeners[i]; - if (listener.eventContext === value) { - this.removeListener(type, listener); - } - } - } - return this; -}; - -function eventListener(model, subpattern, cb) { - if (cb) { - // For signatures: - // model.on('change', 'example.subpath', callback) - // model.at('example').on('change', 'subpath', callback) - var pattern = model.path(subpattern); - return modelEventListener(pattern, cb, model._eventContext); - } - var path = model.path(); - cb = arguments[1]; - // For signature: - // model.at('example').on('change', callback) - if (path) return modelEventListener(path, cb, model._eventContext); - // For signature: - // model.on('normalEvent', callback) - return cb; -} - -function modelEventListener(pattern, cb, eventContext) { - var patternSegments = util.castSegments(pattern.split('.')); - var testFn = testPatternFn(pattern, patternSegments); - - function modelListener(segments, eventArgs) { - var captures = testFn(segments); - if (!captures) return; - - var args = (captures.length) ? captures.concat(eventArgs) : eventArgs; - cb.apply(null, args); - return true; - } - - // Used in Model#removeAllListeners - modelListener.pattern = pattern; - modelListener.patternSegments = patternSegments; - modelListener.eventContext = eventContext; - - return modelListener; -} - -function testPatternFn(pattern, patternSegments) { - if (pattern === '**') { - return function testPattern(segments) { - return [segments.join('.')]; - }; - } - - var endingRest = stripRestWildcard(patternSegments); - - return function testPattern(segments) { - // Any pattern with more segments does not match - var patternLen = patternSegments.length; - if (patternLen > segments.length) return; - - // A pattern with the same number of segments matches if each - // of the segments are wildcards or equal. A shorter pattern matches - // if it ends in a rest wildcard and each of the corresponding - // segments are wildcards or equal. - if (patternLen === segments.length || endingRest) { - var captures = []; - for (var i = 0; i < patternLen; i++) { - var patternSegment = patternSegments[i]; - var segment = segments[i]; - if (patternSegment === '*' || patternSegment === '**') { - captures.push(segment); - continue; - } - if (patternSegment !== segment) return; - } - if (endingRest) { - var remainder = segments.slice(i).join('.'); - captures.push(remainder); - } - return captures; - } - }; -} - -function stripRestWildcard(segments) { - // ['example', '**'] -> ['example']; return true - var lastIndex = segments.length - 1; - if (segments[lastIndex] === '**') { - segments.pop(); - return true; - } - // ['example', 'subpath**'] -> ['example', 'subpath']; return true - var match = /^([^\*]+)\*\*$/.exec(segments[lastIndex]); - if (!match) return false; - segments[lastIndex] = match[1]; - return true; -} diff --git a/lib/Model/filter.js b/lib/Model/filter.js deleted file mode 100644 index f0b8689b0..000000000 --- a/lib/Model/filter.js +++ /dev/null @@ -1,247 +0,0 @@ -var util = require('../util'); -var Model = require('./Model'); -var defaultFns = require('./defaultFns'); - -Model.INITS.push(function(model) { - model.root._filters = new Filters(model); - model.on('all', filterListener); - function filterListener(segments, eventArgs) { - var pass = eventArgs[eventArgs.length - 1]; - var map = model.root._filters.fromMap; - for (var path in map) { - var filter = map[path]; - if (pass.$filter === filter) continue; - if (util.mayImpact(filter.inputSegments, segments)) { - filter.update(pass); - } - } - } -}); - -Model.prototype.filter = function() { - var input, options, fn; - if (arguments.length === 1) { - fn = arguments[0]; - } else if (arguments.length === 2) { - if (this.isPath(arguments[0])) { - input = arguments[0]; - } else { - options = arguments[0]; - } - fn = arguments[1]; - } else { - input = arguments[0]; - options = arguments[1]; - fn = arguments[2]; - } - var inputPath = this.path(input); - return this.root._filters.add(inputPath, fn, null, options); -}; - -Model.prototype.sort = function() { - var input, options, fn; - if (arguments.length === 1) { - fn = arguments[0]; - } else if (arguments.length === 2) { - if (this.isPath(arguments[0])) { - input = arguments[0]; - } else { - options = arguments[0]; - } - fn = arguments[1]; - } else { - input = arguments[0]; - options = arguments[1]; - fn = arguments[2]; - } - if (!fn) throw new TypeError('Sort function is required'); - var inputPath = this.path(input); - return this.root._filters.add(inputPath, null, fn, options); -}; - -Model.prototype.removeAllFilters = function(subpath) { - var segments = this._splitPath(subpath); - this._removeAllFilters(segments); -}; -Model.prototype._removeAllFilters = function(segments) { - var filters = this.root._filters.fromMap; - for (var from in filters) { - if (util.contains(segments, filters[from].fromSegments)) { - filters[from].destroy(); - } - } -}; - -function FromMap() {} -function Filters(model) { - this.model = model; - this.fromMap = new FromMap(); -} - -Filters.prototype.add = function(inputPath, filterFn, sortFn, options) { - return new Filter(this, inputPath, filterFn, sortFn, options); -}; - -Filters.prototype.toJSON = function() { - var out = []; - for (var from in this.fromMap) { - var filter = this.fromMap[from]; - // Don't try to bundle if functions were passed directly instead of by name - if (!filter.bundle) continue; - var args = [from, filter.inputPath, filter.filterName, filter.sortName]; - if (filter.options) args.push(filter.options); - out.push(args); - } - return out; -}; - -function Filter(filters, inputPath, filterFn, sortFn, options) { - this.filters = filters; - this.model = filters.model.pass({$filter: this}); - this.inputPath = inputPath; - this.inputSegments = inputPath.split('.'); - this.filterName = null; - this.sortName = null; - this.bundle = true; - this.filterFn = null; - this.sortFn = null; - this.options = options; - this.skip = options && options.skip; - this.limit = options && options.limit; - if (filterFn) this.filter(filterFn); - if (sortFn) this.sort(sortFn); - this.idsSegments = null; - this.from = null; - this.fromSegments = null; -} - -Filter.prototype.filter = function(fn) { - if (typeof fn === 'function') { - this.filterFn = fn; - this.bundle = false; - return this; - } else if (typeof fn === 'string') { - this.filterName = fn; - this.filterFn = this.model.root._namedFns[fn] || defaultFns[fn]; - if (!this.filterFn) { - throw new TypeError('Filter function not found: ' + fn); - } - } - return this; -}; - -Filter.prototype.sort = function(fn) { - if (!fn) fn = 'asc'; - if (typeof fn === 'function') { - this.sortFn = fn; - this.bundle = false; - return this; - } else if (typeof fn === 'string') { - this.sortName = fn; - this.sortFn = this.model.root._namedFns[fn] || defaultFns[fn]; - if (!this.sortFn) { - throw new TypeError('Sort function not found: ' + fn); - } - } - return this; -}; - -Filter.prototype._slice = function(results) { - if (this.skip == null && this.limit == null) return results; - var begin = this.skip || 0; - // A limit of zero is equivalent to setting no limit - var end; - if (this.limit) end = begin + this.limit; - return results.slice(begin, end); -}; - -Filter.prototype.ids = function() { - var items = this.model._get(this.inputSegments); - var ids = []; - if (!items) return ids; - if (Array.isArray(items)) { - if (this.filterFn) { - for (var i = 0; i < items.length; i++) { - if (this.filterFn.call(this.model, items[i], i, items)) { - ids.push(i); - } - } - } else { - for (var i = 0; i < items.length; i++) ids.push(i); - } - } else { - if (this.filterFn) { - for (var key in items) { - if (items.hasOwnProperty(key) && - this.filterFn.call(this.model, items[key], key, items) - ) { - ids.push(key); - } - } - } else { - ids = Object.keys(items); - } - } - var sortFn = this.sortFn; - if (sortFn) { - ids.sort(function(a, b) { - return sortFn(items[a], items[b]); - }); - } - return this._slice(ids); -}; - -Filter.prototype.get = function() { - var items = this.model._get(this.inputSegments); - var results = []; - if (Array.isArray(items)) { - if (this.filterFn) { - for (var i = 0; i < items.length; i++) { - if (this.filterFn.call(this.model, items[i], i, items)) { - results.push(items[i]); - } - } - } else { - results = items.slice(); - } - } else { - if (this.filterFn) { - for (var key in items) { - if (items.hasOwnProperty(key) && - this.filterFn.call(this.model, items[key], key, items) - ) { - results.push(items[key]); - } - } - } else { - for (var key in items) { - if (items.hasOwnProperty(key)) { - results.push(items[key]); - } - } - } - } - if (this.sortFn) results.sort(this.sortFn); - return this._slice(results); -}; - -Filter.prototype.update = function(pass) { - var ids = this.ids(); - this.model.pass(pass, true)._setArrayDiff(this.idsSegments, ids); -}; - -Filter.prototype.ref = function(from) { - from = this.model.path(from); - this.from = from; - this.fromSegments = from.split('.'); - this.filters.fromMap[from] = this; - this.idsSegments = ['$filters', from.replace(/\./g, '|')]; - this.update(); - return this.model.refList(from, this.inputPath, this.idsSegments.join('.')); -}; - -Filter.prototype.destroy = function() { - delete this.filters.fromMap[this.from]; - this.model._removeRef(this.idsSegments); - this.model._del(this.idsSegments); -}; diff --git a/lib/Model/fn.js b/lib/Model/fn.js deleted file mode 100644 index 4034785e6..000000000 --- a/lib/Model/fn.js +++ /dev/null @@ -1,211 +0,0 @@ -var util = require('../util'); -var Model = require('./Model'); -var defaultFns = require('./defaultFns'); - -function NamedFns() {} - -Model.INITS.push(function(model) { - model.root._namedFns = new NamedFns(); - model.root._fns = new Fns(model); - model.on('all', fnListener); - function fnListener(segments, eventArgs) { - var pass = eventArgs[eventArgs.length - 1]; - var map = model.root._fns.fromMap; - for (var path in map) { - var fn = map[path]; - if (pass.$fn === fn) continue; - if (util.mayImpactAny(fn.inputsSegments, segments)) { - // Mutation affecting input path - fn.onInput(pass); - } else if (util.mayImpact(fn.fromSegments, segments)) { - // Mutation affecting output path - fn.onOutput(pass); - } - } - } -}); - -Model.prototype.fn = function(name, fns) { - this.root._namedFns[name] = fns; -}; - -function parseStartArguments(model, args, hasPath) { - var last = args.pop(); - var fns, name; - if (typeof last === 'string') { - name = last; - } else { - fns = last; - } - var path; - if (hasPath) { - path = model.path(args.shift()); - } - var i = args.length - 1; - var options; - if (model.isPath(args[i])) { - args[i] = model.path(args[i]); - } else { - options = args.pop(); - } - while (i--) { - args[i] = model.path(args[i]); - } - return { - name: name - , path: path - , inputPaths: args - , fns: fns - , options: options - }; -} - -Model.prototype.evaluate = function() { - var args = Array.prototype.slice.call(arguments); - var parsed = parseStartArguments(this, args, false); - return this.root._fns.get(parsed.name, parsed.inputPaths, parsed.fns, parsed.options); -}; - -Model.prototype.start = function() { - var args = Array.prototype.slice.call(arguments); - var parsed = parseStartArguments(this, args, true); - return this.root._fns.start(parsed.name, parsed.path, parsed.inputPaths, parsed.fns, parsed.options); -}; - -Model.prototype.stop = function(subpath) { - var path = this.path(subpath); - this._stop(path); -}; -Model.prototype._stop = function(fromPath) { - this.root._fns.stop(fromPath); -}; - -Model.prototype.stopAll = function(subpath) { - var segments = this._splitPath(subpath); - this._stopAll(segments); -}; -Model.prototype._stopAll = function(segments) { - var fns = this.root._fns.fromMap; - for (var from in fns) { - var fromSegments = fns[from].fromSegments; - if (util.contains(segments, fromSegments)) { - this._stop(from); - } - } -}; - -function FromMap() {} -function Fns(model) { - this.model = model; - this.nameMap = model.root._namedFns; - this.fromMap = new FromMap(); -} - -Fns.prototype.get = function(name, inputPaths, fns, options) { - fns || (fns = this.nameMap[name] || defaultFns[name]); - var fn = new Fn(this.model, name, null, inputPaths, fns, options); - return fn.get(); -}; - -Fns.prototype.start = function(name, path, inputPaths, fns, options) { - fns || (fns = this.nameMap[name] || defaultFns[name]); - var fn = new Fn(this.model, name, path, inputPaths, fns, options); - this.fromMap[path] = fn; - return fn.onInput(); -}; - -Fns.prototype.stop = function(path) { - var fn = this.fromMap[path]; - delete this.fromMap[path]; - return fn; -}; - -Fns.prototype.toJSON = function() { - var out = []; - for (var from in this.fromMap) { - var fn = this.fromMap[from]; - // Don't try to bundle non-named functions that were started via - // model.start directly instead of by name - if (!fn.name) continue; - var args = [fn.from].concat(fn.inputPaths, fn.name); - if (fn.options) args.push(fn.options); - out.push(args); - } - return out; -}; - -function Fn(model, name, from, inputPaths, fns, options) { - this.model = model.pass({$fn: this}); - this.name = name; - this.from = from; - this.inputPaths = inputPaths; - this.options = options; - if (!fns) { - throw new TypeError('Model function not found: ' + name); - } - this.getFn = fns.get || fns; - this.setFn = fns.set; - this.fromSegments = from && from.split('.'); - this.inputsSegments = []; - for (var i = 0; i < this.inputPaths.length; i++) { - var segments = this.inputPaths[i].split('.'); - this.inputsSegments.push(segments); - } - - // Copy can be 'output', 'input', 'both', or 'none' - var copy = (options && options.copy) || 'output'; - this.copyInput = (copy === 'input' || copy === 'both'); - this.copyOutput = (copy === 'output' || copy === 'both'); - - // Mode can be 'diffDeep', 'diff', 'arrayDeep', 'array', or 'set' - this.mode = (options && options.mode) || 'diffDeep'; -} - -Fn.prototype.apply = function(fn, inputs) { - for (var i = 0, len = this.inputsSegments.length; i < len; i++) { - var input = this.model._get(this.inputsSegments[i]); - inputs.push(this.copyInput ? util.deepCopy(input) : input); - } - return fn.apply(this.model, inputs); -}; - -Fn.prototype.get = function() { - return this.apply(this.getFn, []); -}; - -Fn.prototype.set = function(value, pass) { - if (!this.setFn) return; - var out = this.apply(this.setFn, [value]); - if (!out) return; - var inputsSegments = this.inputsSegments; - var model = this.model.pass(pass, true); - for (var key in out) { - var value = (this.copyOutput) ? util.deepCopy(out[key]) : out[key]; - this._setValue(model, inputsSegments[key], value); - } -}; - -Fn.prototype.onInput = function(pass) { - var value = (this.copyOutput) ? util.deepCopy(this.get()) : this.get(); - this._setValue(this.model.pass(pass, true), this.fromSegments, value); - return value; -}; - -Fn.prototype.onOutput = function(pass) { - var value = this.model._get(this.fromSegments); - return this.set(value, pass); -}; - -Fn.prototype._setValue = function(model, segments, value) { - if (this.mode === 'diffDeep') { - model._setDiffDeep(segments, value); - } else if (this.mode === 'diff') { - model._setDiff(segments, value); - } else if (this.mode === 'arrayDeep') { - model._setArrayDiffDeep(segments, value); - } else if (this.mode === 'array') { - model._setArrayDiff(segments, value); - } else { - model._set(segments, value); - } -}; diff --git a/lib/Model/index.js b/lib/Model/index.js deleted file mode 100644 index 11acb86cf..000000000 --- a/lib/Model/index.js +++ /dev/null @@ -1,24 +0,0 @@ -module.exports = require('./Model'); -var util = require('../util'); - -// Extend model on both server and client // -require('./unbundle'); -require('./events'); -require('./paths'); -require('./collections'); -require('./mutators'); -require('./setDiff'); - -require('./connection'); -require('./subscriptions'); -require('./Query'); -require('./contexts'); - -require('./fn'); -require('./filter'); -require('./refList'); -require('./ref'); - -// Extend model for server // -util.serverRequire(module, './bundle'); -util.serverRequire(module, './connection.server'); diff --git a/lib/Model/mutators.js b/lib/Model/mutators.js deleted file mode 100644 index 1a1d904a5..000000000 --- a/lib/Model/mutators.js +++ /dev/null @@ -1,569 +0,0 @@ -var util = require('../util'); -var Model = require('./Model'); - -Model.prototype._mutate = function(segments, fn, cb) { - cb = this.wrapCallback(cb); - var collectionName = segments[0]; - var id = segments[1]; - if (!collectionName || !id) { - var message = fn.name + ' must be performed under a collection ' + - 'and document id. Invalid path: ' + segments.join('.'); - return cb(new Error(message)); - } - var doc = this.getOrCreateDoc(collectionName, id); - var docSegments = segments.slice(2); - return fn(doc, docSegments, cb); -}; - -Model.prototype.set = function() { - var subpath, value, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - value = arguments[1]; - } else { - subpath = arguments[0]; - value = arguments[1]; - cb = arguments[2]; - } - var segments = this._splitPath(subpath); - return this._set(segments, value, cb); -}; -Model.prototype._set = function(segments, value, cb) { - segments = this._dereference(segments); - var model = this; - function set(doc, docSegments, fnCb) { - var previous = doc.set(docSegments, value, fnCb); - // On setting the entire doc, remote docs sometimes do a copy to add the - // id without it being stored in the database by ShareJS - if (docSegments.length === 0) value = doc.get(docSegments); - model.emit('change', segments, [value, previous, model._pass]); - return previous; - } - return this._mutate(segments, set, cb); -}; - -Model.prototype.setEach = function() { - var subpath, object, cb; - if (arguments.length === 1) { - object = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - object = arguments[1]; - } else { - subpath = arguments[0]; - object = arguments[1]; - cb = arguments[2]; - } - var segments = this._splitPath(subpath); - return this._setEach(segments, object, cb); -}; -Model.prototype._setEach = function(segments, object, cb) { - segments = this._dereference(segments); - var group = util.asyncGroup(this.wrapCallback(cb)); - for (var key in object) { - var value = object[key]; - this._set(segments.concat(key), value, group()); - } -}; - -Model.prototype.add = function() { - var subpath, value, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - if (typeof arguments[1] === 'function') { - value = arguments[0]; - cb = arguments[1]; - } else { - subpath = arguments[0]; - value = arguments[1]; - } - } else { - subpath = arguments[0]; - value = arguments[1]; - cb = arguments[2]; - } - var segments = this._splitPath(subpath); - return this._add(segments, value, cb); -}; -Model.prototype._add = function(segments, value, cb) { - if (typeof value !== 'object') { - var message = 'add requires an object value. Invalid value: ' + value; - cb = this.wrapCallback(cb); - return cb(new Error(message)); - } - var id = value.id || this.id(); - value.id = id; - this._set(segments.concat(id), value, cb); - return id; -}; - -Model.prototype.setNull = function() { - var subpath, value, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - value = arguments[1]; - } else { - subpath = arguments[0]; - value = arguments[1]; - cb = arguments[2]; - } - var segments = this._splitPath(subpath); - return this._setNull(segments, value, cb); -}; -Model.prototype._setNull = function(segments, value, cb) { - segments = this._dereference(segments); - var model = this; - function setNull(doc, docSegments, fnCb) { - var previous = doc.get(docSegments); - if (previous != null) { - fnCb(); - return previous; - } - doc.set(docSegments, value, fnCb); - model.emit('change', segments, [value, previous, model._pass]); - return value; - } - return this._mutate(segments, setNull, cb); -}; - -Model.prototype.del = function() { - var subpath, cb; - if (arguments.length === 1) { - if (typeof arguments[0] === 'function') { - cb = arguments[0]; - } else { - subpath = arguments[0]; - } - } else { - subpath = arguments[0]; - cb = arguments[1]; - } - var segments = this._splitPath(subpath); - return this._del(segments, cb); -}; -Model.prototype._del = function(segments, cb) { - segments = this._dereference(segments); - var model = this; - function del(doc, docSegments, fnCb) { - var previous = doc.del(docSegments, fnCb); - // When deleting an entire document, also remove the reference to the - // document object from its collection - if (segments.length === 2) { - var collectionName = segments[0]; - var id = segments[1]; - model.root.collections[collectionName].remove(id); - } - model.emit('change', segments, [void 0, previous, model._pass]); - return previous; - } - return this._mutate(segments, del, cb); -}; - -Model.prototype.increment = function() { - var subpath, byNumber, cb; - if (arguments.length === 1) { - if (typeof arguments[0] === 'function') { - cb = arguments[0]; - } else if (typeof arguments[0] === 'number') { - byNumber = arguments[0]; - } else { - subpath = arguments[0]; - } - } else if (arguments.length === 2) { - if (typeof arguments[1] === 'function') { - cb = arguments[1]; - if (typeof arguments[0] === 'number') { - byNumber = arguments[0]; - } else { - subpath = arguments[0]; - } - } else { - subpath = arguments[0]; - byNumber = arguments[1]; - } - } else { - subpath = arguments[0]; - byNumber = arguments[1]; - cb = arguments[2]; - } - var segments = this._splitPath(subpath); - return this._increment(segments, byNumber, cb); -}; -Model.prototype._increment = function(segments, byNumber, cb) { - segments = this._dereference(segments); - if (byNumber == null) byNumber = 1; - var model = this; - function increment(doc, docSegments, fnCb) { - var value = doc.increment(docSegments, byNumber, fnCb); - var previous = value - byNumber; - model.emit('change', segments, [value, previous, model._pass]); - return value; - } - return this._mutate(segments, increment, cb); -}; - -Model.prototype.push = function() { - var subpath, value, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - value = arguments[1]; - } else { - subpath = arguments[0]; - value = arguments[1]; - cb = arguments[2]; - } - var segments = this._splitPath(subpath); - return this._push(segments, value, cb); -}; -Model.prototype._push = function(segments, value, cb) { - var forArrayMutator = true; - segments = this._dereference(segments, forArrayMutator); - var model = this; - function push(doc, docSegments, fnCb) { - var length = doc.push(docSegments, value, fnCb); - model.emit('insert', segments, [length - 1, [value], model._pass]); - return length; - } - return this._mutate(segments, push, cb); -}; - -Model.prototype.unshift = function() { - var subpath, value, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - value = arguments[1]; - } else { - subpath = arguments[0]; - value = arguments[1]; - cb = arguments[2]; - } - var segments = this._splitPath(subpath); - return this._unshift(segments, value, cb); -}; -Model.prototype._unshift = function(segments, value, cb) { - var forArrayMutator = true; - segments = this._dereference(segments, forArrayMutator); - var model = this; - function unshift(doc, docSegments, fnCb) { - var length = doc.unshift(docSegments, value, fnCb); - model.emit('insert', segments, [0, [value], model._pass]); - return length; - } - return this._mutate(segments, unshift, cb); -}; - -Model.prototype.insert = function() { - var subpath, index, values, cb; - if (arguments.length === 1) { - throw new Error('Not enough arguments for insert'); - } else if (arguments.length === 2) { - index = arguments[0]; - values = arguments[1]; - } else if (arguments.length === 3) { - subpath = arguments[0]; - index = arguments[1]; - values = arguments[2]; - } else { - subpath = arguments[0]; - index = arguments[1]; - values = arguments[2]; - cb = arguments[3]; - } - var segments = this._splitPath(subpath); - return this._insert(segments, +index, values, cb); -}; -Model.prototype._insert = function(segments, index, values, cb) { - var forArrayMutator = true; - segments = this._dereference(segments, forArrayMutator); - var model = this; - function insert(doc, docSegments, fnCb) { - var inserted = (Array.isArray(values)) ? values : [values]; - var length = doc.insert(docSegments, index, inserted, fnCb); - model.emit('insert', segments, [index, inserted, model._pass]); - return length; - } - return this._mutate(segments, insert, cb); -}; - -Model.prototype.pop = function() { - var subpath, cb; - if (arguments.length === 1) { - if (typeof arguments[0] === 'function') { - cb = arguments[0]; - } else { - subpath = arguments[0]; - } - } else { - subpath = arguments[0]; - cb = arguments[1]; - } - var segments = this._splitPath(subpath); - return this._pop(segments, cb); -}; -Model.prototype._pop = function(segments, cb) { - var forArrayMutator = true; - segments = this._dereference(segments, forArrayMutator); - var model = this; - function pop(doc, docSegments, fnCb) { - var arr = doc.get(docSegments); - var length = arr && arr.length; - if (!length) { - fnCb(); - return; - } - var value = doc.pop(docSegments, fnCb); - model.emit('remove', segments, [length - 1, [value], model._pass]); - return value; - } - return this._mutate(segments, pop, cb); -}; - -Model.prototype.shift = function() { - var subpath, cb; - if (arguments.length === 1) { - if (typeof arguments[0] === 'function') { - cb = arguments[0]; - } else { - subpath = arguments[0]; - } - } else { - subpath = arguments[0]; - cb = arguments[1]; - } - var segments = this._splitPath(subpath); - return this._shift(segments, cb); -}; -Model.prototype._shift = function(segments, cb) { - var forArrayMutator = true; - segments = this._dereference(segments, forArrayMutator); - var model = this; - function shift(doc, docSegments, fnCb) { - var arr = doc.get(docSegments); - var length = arr && arr.length; - if (!length) { - fnCb(); - return; - } - var value = doc.shift(docSegments, fnCb); - model.emit('remove', segments, [0, [value], model._pass]); - return value; - } - return this._mutate(segments, shift, cb); -}; - -Model.prototype.remove = function() { - var subpath, index, howMany, cb; - if (arguments.length === 1) { - index = arguments[0]; - } else if (arguments.length === 2) { - if (typeof arguments[1] === 'function') { - cb = arguments[1]; - if (typeof arguments[0] === 'number') { - index = arguments[0]; - } else { - subpath = arguments[0]; - } - } else { - if (typeof arguments[0] === 'number') { - index = arguments[0]; - howMany = arguments[1]; - } else { - subpath = arguments[0]; - index = arguments[1]; - } - } - } else if (arguments.length === 3) { - if (typeof arguments[2] === 'function') { - cb = arguments[2]; - if (typeof arguments[0] === 'number') { - index = arguments[0]; - howMany = arguments[1]; - } else { - subpath = arguments[0]; - index = arguments[1]; - } - } else { - subpath = arguments[0]; - index = arguments[1]; - howMany = arguments[2]; - } - } else { - subpath = arguments[0]; - index = arguments[1]; - howMany = arguments[2]; - cb = arguments[3]; - } - var segments = this._splitPath(subpath); - if (index == null) index = segments.pop(); - return this._remove(segments, +index, howMany, cb); -}; -Model.prototype._remove = function(segments, index, howMany, cb) { - var forArrayMutator = true; - segments = this._dereference(segments, forArrayMutator); - if (howMany == null) howMany = 1; - var model = this; - function remove(doc, docSegments, fnCb) { - var removed = doc.remove(docSegments, index, howMany, fnCb); - model.emit('remove', segments, [index, removed, model._pass]); - return removed; - } - return this._mutate(segments, remove, cb); -}; - -Model.prototype.move = function() { - var subpath, from, to, howMany, cb; - if (arguments.length === 1) { - throw new Error('Not enough arguments for move'); - } else if (arguments.length === 2) { - from = arguments[0]; - to = arguments[1]; - } else if (arguments.length === 3) { - if (typeof arguments[2] === 'function') { - from = arguments[0]; - to = arguments[1]; - cb = arguments[2]; - } else if (typeof arguments[0] === 'number') { - from = arguments[0]; - to = arguments[1]; - howMany = arguments[2]; - } else { - subpath = arguments[0]; - from = arguments[1]; - to = arguments[2]; - } - } else if (arguments.length === 4) { - if (typeof arguments[3] === 'function') { - cb = arguments[3]; - if (typeof arguments[0] === 'number') { - from = arguments[0]; - to = arguments[1]; - howMany = arguments[2]; - } else { - subpath = arguments[0]; - from = arguments[1]; - to = arguments[2]; - } - } else { - subpath = arguments[0]; - from = arguments[1]; - to = arguments[2]; - howMany = arguments[3]; - } - } else { - subpath = arguments[0]; - from = arguments[1]; - to = arguments[2]; - howMany = arguments[3]; - cb = arguments[4]; - } - var segments = this._splitPath(subpath); - return this._move(segments, from, to, howMany, cb); -}; -Model.prototype._move = function(segments, from, to, howMany, cb) { - var forArrayMutator = true; - segments = this._dereference(segments, forArrayMutator); - if (howMany == null) howMany = 1; - var model = this; - function move(doc, docSegments, fnCb) { - // Cast to numbers - from = +from; - to = +to; - // Convert negative indices into positive - if (from < 0 || to < 0) { - var len = doc.get(docSegments).length; - if (from < 0) from += len; - if (to < 0) to += len; - } - var moved = doc.move(docSegments, from, to, howMany, fnCb); - model.emit('move', segments, [from, to, moved.length, model._pass]); - return moved; - } - return this._mutate(segments, move, cb); -}; - -Model.prototype.stringInsert = function() { - var subpath, index, text, cb; - if (arguments.length === 1) { - throw new Error('Not enough arguments for stringInsert'); - } else if (arguments.length === 2) { - index = arguments[0]; - text = arguments[1]; - } else if (arguments.length === 3) { - if (typeof arguments[2] === 'function') { - index = arguments[0]; - text = arguments[1]; - cb = arguments[2]; - } else { - subpath = arguments[0]; - index = arguments[1]; - text = arguments[2]; - } - } else { - subpath = arguments[0]; - index = arguments[1]; - text = arguments[2]; - cb = arguments[3]; - } - var segments = this._splitPath(subpath); - return this._stringInsert(segments, index, text, cb); -}; -Model.prototype._stringInsert = function(segments, index, text, cb) { - segments = this._dereference(segments); - var model = this; - function stringInsert(doc, docSegments, fnCb) { - var previous = doc.stringInsert(docSegments, index, text, fnCb); - var value = doc.get(docSegments); - var pass = model.pass({$type: 'stringInsert', index: index, text: text})._pass; - model.emit('change', segments, [value, previous, pass]); - return; - } - return this._mutate(segments, stringInsert, cb); -}; - -Model.prototype.stringRemove = function() { - var subpath, index, howMany, cb; - if (arguments.length === 1) { - throw new Error('Not enough arguments for stringRemove'); - } else if (arguments.length === 2) { - index = arguments[0]; - howMany = arguments[1]; - } else if (arguments.length === 3) { - if (typeof arguments[2] === 'function') { - index = arguments[0]; - howMany = arguments[1]; - cb = arguments[2]; - } else { - subpath = arguments[0]; - index = arguments[1]; - howMany = arguments[2]; - } - } else { - subpath = arguments[0]; - index = arguments[1]; - howMany = arguments[2]; - cb = arguments[3]; - } - var segments = this._splitPath(subpath); - return this._stringRemove(segments, index, howMany, cb); -}; -Model.prototype._stringRemove = function(segments, index, howMany, cb) { - segments = this._dereference(segments); - var model = this; - function stringRemove(doc, docSegments, fnCb) { - var previous = doc.stringRemove(docSegments, index, howMany, fnCb); - var value = doc.get(docSegments); - var pass = model.pass({$type: 'stringRemove', index: index, howMany: howMany})._pass; - model.emit('change', segments, [value, previous, pass]); - return; - } - return this._mutate(segments, stringRemove, cb); -}; diff --git a/lib/Model/paths.js b/lib/Model/paths.js deleted file mode 100644 index 65f7d4f99..000000000 --- a/lib/Model/paths.js +++ /dev/null @@ -1,80 +0,0 @@ -var Model = require('./Model'); - -exports.mixin = {}; - -Model.prototype._splitPath = function(subpath) { - var path = this.path(subpath); - return (path && path.split('.')) || []; -}; - -/** - * Returns the path equivalent to the path of the current scoped model plus - * (optionally) a suffix subpath - * - * @optional @param {String} subpath - * @return {String} absolute path - * @api public - */ -Model.prototype.path = function(subpath) { - if (subpath == null || subpath === '') return (this._at) ? this._at : ''; - if (typeof subpath === 'string' || typeof subpath === 'number') { - return (this._at) ? this._at + '.' + subpath : '' + subpath; - } - if (typeof subpath.path === 'function') return subpath.path(); -}; - -Model.prototype.isPath = function(subpath) { - return this.path(subpath) != null; -}; - -Model.prototype.scope = function(path) { - var model = this._child(); - model._at = path; - return model; -}; - -/** - * Create a model object scoped to a particular path. - * Example: - * var user = model.at('users.1'); - * user.set('username', 'brian'); - * user.on('push', 'todos', function(todo) { - * // ... - * }); - * - * @param {String} segment - * @return {Model} a scoped model - * @api public - */ -Model.prototype.at = function(subpath) { - var path = this.path(subpath); - return this.scope(path); -}; - -/** - * Returns a model scope that is a number of levels above the current scoped - * path. Number of levels defaults to 1, so this method called without - * arguments returns the model scope's parent model scope. - * - * @optional @param {Number} levels - * @return {Model} a scoped model - */ -Model.prototype.parent = function(levels) { - if (levels == null) levels = 1; - var segments = this._splitPath(); - var len = Math.max(0, segments.length - levels); - var path = segments.slice(0, len).join('.'); - return this.scope(path); -}; - -/** - * Returns the last property segment of the current model scope path - * - * @optional @param {String} path - * @return {String} - */ -Model.prototype.leaf = function(path) { - if (!path) path = this.path(); - var i = path.lastIndexOf('.'); - return path.slice(i + 1); -}; diff --git a/lib/Model/ref.js b/lib/Model/ref.js deleted file mode 100644 index ed25fa769..000000000 --- a/lib/Model/ref.js +++ /dev/null @@ -1,347 +0,0 @@ -var util = require('../util'); -var Model = require('./Model'); - -Model.INITS.push(function(model) { - var root = model.root; - root._refs = new Refs(root); - addIndexListeners(root); - addListener(root, 'change', refChange); - addListener(root, 'load', refLoad); - addListener(root, 'unload', refUnload); - addListener(root, 'insert', refInsert); - addListener(root, 'remove', refRemove); - addListener(root, 'move', refMove); - addListener(root, 'stringInsert', refStringInsert); - addListener(root, 'stringRemove', refStringRemove); -}); - -function addIndexListeners(model) { - model.on('insert', function refInsertIndex(segments, eventArgs) { - var index = eventArgs[0]; - var howMany = eventArgs[1].length; - function patchInsert(refIndex) { - return (index <= refIndex) ? refIndex + howMany : refIndex; - } - onIndexChange(segments, patchInsert); - }); - model.on('remove', function refRemoveIndex(segments, eventArgs) { - var index = eventArgs[0]; - var howMany = eventArgs[1].length; - function patchRemove(refIndex) { - return (index <= refIndex) ? refIndex - howMany : refIndex; - } - onIndexChange(segments, patchRemove); - }); - model.on('move', function refMoveIndex(segments, eventArgs) { - var from = eventArgs[0]; - var to = eventArgs[1]; - var howMany = eventArgs[2]; - function patchMove(refIndex) { - // If the index was moved itself - if (from <= refIndex && refIndex < from + howMany) { - return refIndex + to - from; - } - // Remove part of a move - if (from <= refIndex) refIndex -= howMany; - // Insert part of a move - if (to <= refIndex) refIndex += howMany; - return refIndex; - } - onIndexChange(segments, patchMove); - }); - function onIndexChange(segments, patch) { - var fromMap = model._refs.fromMap; - for (var from in fromMap) { - var ref = fromMap[from]; - if (!(ref.updateIndices && - util.contains(segments, ref.toSegments) && - ref.toSegments.length > segments.length)) continue; - var index = +ref.toSegments[segments.length]; - var patched = patch(index); - if (index === patched) continue; - model._refs.remove(from); - ref.toSegments[segments.length] = '' + patched; - ref.to = ref.toSegments.join('.'); - model._refs._add(ref); - } - } -} - -function refChange(model, dereferenced, eventArgs, segments) { - var value = eventArgs[0]; - // Detect if we are deleting vs. setting to undefined - if (value === void 0) { - var parentSegments = segments.slice(); - var last = parentSegments.pop(); - var parent = model._get(parentSegments); - if (!parent || !(last in parent)) { - model._del(dereferenced); - return; - } - } - model._set(dereferenced, value); -} -function refLoad(model, dereferenced, eventArgs) { - var value = eventArgs[0]; - model._set(dereferenced, value); -} -function refUnload(model, dereferenced, eventArgs) { - model._del(dereferenced); -} -function refInsert(model, dereferenced, eventArgs) { - var index = eventArgs[0]; - var values = eventArgs[1]; - model._insert(dereferenced, index, values); -} -function refRemove(model, dereferenced, eventArgs) { - var index = eventArgs[0]; - var howMany = eventArgs[1].length; - model._remove(dereferenced, index, howMany); -} -function refMove(model, dereferenced, eventArgs) { - var from = eventArgs[0]; - var to = eventArgs[1]; - var howMany = eventArgs[2]; - model._move(dereferenced, from, to, howMany); -} -function refStringInsert(model, dereferenced, eventArgs) { - var index = eventArgs[0]; - var text = eventArgs[1]; - model._stringInsert(dereferenced, index, text); -} -function refStringRemove(model, dereferenced, eventArgs) { - var index = eventArgs[0]; - var howMany = eventArgs[1]; - model._stringRemove(dereferenced, index, howMany); -} - -function addListener(model, type, fn) { - model.on(type, refListener); - function refListener(segments, eventArgs) { - var pass = eventArgs[eventArgs.length - 1]; - // Find cases where an event is emitted on a path where a reference - // is pointing. All original mutations happen on the fully dereferenced - // location, so this detection only needs to happen in one direction - var toMap = model._refs.toMap; - var subpath; - for (var i = 0, len = segments.length; i < len; i++) { - subpath = (subpath) ? subpath + '.' + segments[i] : segments[i]; - // If a ref is found pointing to a matching subpath, re-emit on the - // place where the reference is coming from as if the mutation also - // occured at that path - var refs = toMap[subpath]; - if (!refs) continue; - var remaining = segments.slice(i + 1); - for (var refIndex = 0, numRefs = refs.length; refIndex < numRefs; refIndex++) { - var ref = refs[refIndex]; - var dereferenced = ref.fromSegments.concat(remaining); - // The value may already be up to date via object reference. If so, - // simply re-emit the event. Otherwise, perform the same mutation on - // the ref's path - if (model._get(dereferenced) === model._get(segments)) { - model.emit(type, dereferenced, eventArgs); - } else { - var setterModel = ref.model.pass(pass, true); - setterModel._dereference = noopDereference; - fn(setterModel, dereferenced, eventArgs, segments); - } - } - } - // If a ref points to a child of a matching subpath, get the value in - // case it has changed and set if different - var parentToMap = model._refs.parentToMap; - var refs = parentToMap[subpath]; - if (!refs) return; - for (var refIndex = 0, numRefs = refs.length; refIndex < numRefs; refIndex++) { - var ref = refs[refIndex]; - var value = model._get(ref.toSegments); - var previous = model._get(ref.fromSegments); - if (previous !== value) { - var setterModel = ref.model.pass(pass, true); - setterModel._dereference = noopDereference; - setterModel._set(ref.fromSegments, value); - } - } - } -} - -Model.prototype._canRefTo = function(value) { - return this.isPath(value) || (value && typeof value.ref === 'function'); -}; - -Model.prototype.ref = function() { - var from, to, options; - if (arguments.length === 1) { - to = arguments[0]; - } else if (arguments.length === 2) { - if (this._canRefTo(arguments[1])) { - from = arguments[0]; - to = arguments[1]; - } else { - to = arguments[0]; - options = arguments[1]; - } - } else { - from = arguments[0]; - to = arguments[1]; - options = arguments[2]; - } - var fromPath = this.path(from); - var toPath = this.path(to); - // Make ref to reffable object, such as query or filter - if (!toPath) return to.ref(fromPath); - var fromSegments = fromPath.split('.'); - if (fromSegments.length < 2) { - throw new Error('ref must be performed under a collection ' + - 'and document id. Invalid path: ' + fromPath); - } - this.root._refs.remove(fromPath); - var value = this.get(to); - this._set(fromSegments, value); - this.root._refs.add(fromPath, toPath, options); - return this.scope(fromPath); -}; - -Model.prototype.removeRef = function(subpath) { - var segments = this._splitPath(subpath); - var fromPath = segments.join('.'); - this._removeRef(segments, fromPath); -}; -Model.prototype._removeRef = function(segments, fromPath) { - this.root._refs.remove(fromPath); - this.root._refLists.remove(fromPath); - this._del(segments); -}; - -Model.prototype.removeAllRefs = function(subpath) { - var segments = this._splitPath(subpath); - this._removeAllRefs(segments); -}; -Model.prototype._removeAllRefs = function(segments) { - this._removeMapRefs(segments, this.root._refs.fromMap); - this._removeMapRefs(segments, this.root._refLists.fromMap); -}; -Model.prototype._removeMapRefs = function(segments, map) { - for (var from in map) { - var fromSegments = map[from].fromSegments; - if (util.contains(segments, fromSegments)) { - this._removeRef(fromSegments, from); - } - } -}; - -Model.prototype.dereference = function(subpath) { - var segments = this._splitPath(subpath); - return this._dereference(segments).join('.'); -}; - -Model.prototype._dereference = function(segments, forArrayMutator, ignore) { - if (segments.length === 0) return segments; - var refs = this.root._refs.fromMap; - var refLists = this.root._refLists.fromMap; - var doAgain; - do { - var subpath = ''; - doAgain = false; - for (var i = 0, len = segments.length; i < len; i++) { - subpath = (subpath) ? subpath + '.' + segments[i] : segments[i]; - - var ref = refs[subpath]; - if (ref) { - var remaining = segments.slice(i + 1); - segments = ref.toSegments.concat(remaining); - doAgain = true; - break; - } - - var refList = refLists[subpath]; - if (refList && refList !== ignore) { - var belowDescendant = i + 2 < len; - var belowChild = i + 1 < len; - if (!(belowDescendant || forArrayMutator && belowChild)) continue; - segments = refList.dereference(segments, i); - doAgain = true; - break; - } - } - } while (doAgain); - // If a dereference fails, return a path that will result in a null value - // instead of a path to everything in the model - if (segments.length === 0) return ['$null']; - return segments; -}; - -function noopDereference(segments) { - return segments; -} - -function Ref(model, from, to, options) { - this.model = model && model.pass({$ref: this}); - this.from = from; - this.to = to; - this.fromSegments = from.split('.'); - this.toSegments = to.split('.'); - this.parentTos = []; - for (var i = 1, len = this.toSegments.length; i < len; i++) { - var parentTo = this.toSegments.slice(0, i).join('.'); - this.parentTos.push(parentTo); - } - this.updateIndices = options && options.updateIndices; -} -function FromMap() {} -function ToMap() {} - -function Refs(model) { - this.model = model; - this.fromMap = new FromMap(); - this.toMap = new ToMap(); - this.parentToMap = new ToMap(); -} - -Refs.prototype.add = function(from, to, options) { - var ref = new Ref(this.model, from, to, options); - return this._add(ref); -}; - -Refs.prototype._add = function(ref) { - this.fromMap[ref.from] = ref; - listMapAdd(this.toMap, ref.to, ref); - for (var i = 0, len = ref.parentTos.length; i < len; i++) { - listMapAdd(this.parentToMap, ref.parentTos[i], ref); - } - return ref; -}; - -Refs.prototype.remove = function(from) { - var ref = this.fromMap[from]; - if (!ref) return; - delete this.fromMap[from]; - listMapRemove(this.toMap, ref.to, ref); - for (var i = 0, len = ref.parentTos.length; i < len; i++) { - listMapRemove(this.parentToMap, ref.parentTos[i], ref); - } - return ref; -}; - -Refs.prototype.toJSON = function() { - var out = []; - for (var from in this.fromMap) { - var ref = this.fromMap[from]; - out.push([ref.from, ref.to]); - } - return out; -}; - -function listMapAdd(map, name, item) { - map[name] || (map[name] = []); - map[name].push(item); -} - -function listMapRemove(map, name, item) { - var items = map[name]; - if (!items) return; - var index = items.indexOf(item); - if (index === -1) return; - items.splice(index, 1); - if (!items.length) delete map[name]; -} diff --git a/lib/Model/setDiff.js b/lib/Model/setDiff.js deleted file mode 100644 index 06381b214..000000000 --- a/lib/Model/setDiff.js +++ /dev/null @@ -1,207 +0,0 @@ -var util = require('../util'); -var Model = require('./Model'); -var arrayDiff = require('arraydiff'); -var deepEqual = util.deepEqual; - -Model.prototype.setDiff = function() { - var subpath, value, options, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - value = arguments[1]; - } else if (arguments.length === 3) { - subpath = arguments[0]; - value = arguments[1]; - if (typeof arguments[2] === 'function') { - cb = arguments[2]; - } else { - options = arguments[2]; - } - } else { - subpath = arguments[0]; - value = arguments[1]; - options = arguments[2]; - cb = arguments[3]; - } - var segments = this._splitPath(subpath); - return this._setDiff(segments, value, options, cb); -}; -Model.prototype.setDiffDeep = function() { - var subpath, value, options, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - value = arguments[1]; - } else if (arguments.length === 3) { - subpath = arguments[0]; - value = arguments[1]; - if (typeof arguments[2] === 'function') { - cb = arguments[2]; - } else { - options = arguments[2]; - } - } else { - subpath = arguments[0]; - value = arguments[1]; - options = arguments[2]; - cb = arguments[3]; - } - var segments = this._splitPath(subpath); - return this._setDiffDeep(segments, value, options, cb); -}; -Model.prototype._setDiffDeep = function(segments, value, options, cb) { - if (options) { - options.equal = deepEqual; - } else { - options = {equal: deepEqual}; - } - return this._setDiff(segments, value, options, cb); -}; -Model.prototype._setDiff = function(segments, value, options, cb) { - var equalFn = (options && options.equal) || util.equal; - var before = this._get(segments); - cb = this.wrapCallback(cb); - if (equalFn(before, value)) return cb(); - - var group = util.asyncGroup(cb); - var finished = group(); - doDiff(this, segments, before, value, equalFn, group); - finished(); -}; -function doDiff(model, segments, before, after, equalFn, group) { - if (typeof before !== 'object' || !before || - typeof after !== 'object' || !after) { - // Set the entire value if not diffable - model._set(segments, after, group()); - return; - } - if (Array.isArray(before) && Array.isArray(after)) { - var diff = arrayDiff(before, after, equalFn); - if (!diff.length) return; - // If the only change is a single item replacement, diff the item instead - if ( - diff.length === 2 && - diff[0].index === diff[1].index && - diff[0] instanceof arrayDiff.RemoveDiff && - diff[0].howMany === 1 && - diff[1] instanceof arrayDiff.InsertDiff && - diff[1].values.length === 1 - ) { - var index = diff[0].index; - var itemSegments = segments.concat(index); - doDiff(model, itemSegments, before[index], after[index], equalFn, group); - return; - } - model._applyArrayDiff(segments, diff, group()); - return; - } - - // Delete keys that were in before but not after - for (var key in before) { - if (key in after) continue; - var itemSegments = segments.concat(key); - model._del(itemSegments, group()); - } - - // Diff each property in after - for (var key in after) { - if (equalFn(before[key], after[key])) continue; - var itemSegments = segments.concat(key); - doDiff(model, itemSegments, before[key], after[key], equalFn, group); - } -} - -Model.prototype.setArrayDiff = function() { - var subpath, value, options, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - value = arguments[1]; - } else if (arguments.length === 3) { - subpath = arguments[0]; - value = arguments[1]; - if (typeof arguments[2] === 'function') { - cb = arguments[2]; - } else { - options = arguments[2]; - } - } else { - subpath = arguments[0]; - value = arguments[1]; - options = arguments[2]; - cb = arguments[3]; - } - var segments = this._splitPath(subpath); - return this._setArrayDiff(segments, value, options, cb); -}; -Model.prototype.setArrayDiffDeep = function() { - var subpath, value, options, cb; - if (arguments.length === 1) { - value = arguments[0]; - } else if (arguments.length === 2) { - subpath = arguments[0]; - value = arguments[1]; - } else if (arguments.length === 3) { - subpath = arguments[0]; - value = arguments[1]; - if (typeof arguments[2] === 'function') { - cb = arguments[2]; - } else { - options = arguments[2]; - } - } else { - subpath = arguments[0]; - value = arguments[1]; - options = arguments[2]; - cb = arguments[3]; - } - var segments = this._splitPath(subpath); - return this._setArrayDiffDeep(segments, value, options, cb); -}; -Model.prototype._setArrayDiffDeep = function(segments, value, options, cb) { - if (options) { - options.equal = deepEqual; - } else { - options = {equal: deepEqual}; - } - return this._setArrayDiff(segments, value, options, cb); -}; -Model.prototype._setArrayDiff = function(segments, value, options, cb) { - var before = this._get(segments); - if (before === value) return this.wrapCallback(cb)(); - if (!Array.isArray(before) || !Array.isArray(value)) { - this._set(segments, value, cb); - return; - } - var equalFn = options && options.equal; - var diff = arrayDiff(before, value, equalFn); - this._applyArrayDiff(segments, diff, cb); -}; -Model.prototype._applyArrayDiff = function(segments, diff, cb) { - if (!diff.length) return this.wrapCallback(cb)(); - segments = this._dereference(segments); - var model = this; - function applyArrayDiff(doc, docSegments, fnCb) { - var group = util.asyncGroup(fnCb); - for (var i = 0, len = diff.length; i < len; i++) { - var item = diff[i]; - if (item instanceof arrayDiff.InsertDiff) { - // Insert - doc.insert(docSegments, item.index, item.values, group()); - model.emit('insert', segments, [item.index, item.values, model._pass]); - } else if (item instanceof arrayDiff.RemoveDiff) { - // Remove - var removed = doc.remove(docSegments, item.index, item.howMany, group()); - model.emit('remove', segments, [item.index, removed, model._pass]); - } else if (item instanceof arrayDiff.MoveDiff) { - // Move - var moved = doc.move(docSegments, item.from, item.to, item.howMany, group()); - model.emit('move', segments, [item.from, item.to, moved.length, model._pass]); - } - } - } - return this._mutate(segments, applyArrayDiff, cb); -}; diff --git a/lib/Model/subscriptions.js b/lib/Model/subscriptions.js deleted file mode 100644 index ab3beb4f5..000000000 --- a/lib/Model/subscriptions.js +++ /dev/null @@ -1,249 +0,0 @@ -var util = require('../util'); -var Model = require('./Model'); -var Query = require('./Query'); - -Model.INITS.push(function(model, options) { - model.root.fetchOnly = options.fetchOnly; - model.root.unloadDelay = options.unloadDelay || (util.isServer) ? 0 : 1000; - - // Keeps track of the count of fetches (that haven't been undone by an - // unfetch) per doc. Maps doc id to the fetch count. - model.root._fetchedDocs = new FetchedDocs(); - - // Keeps track of the count of subscribes (that haven't been undone by an - // unsubscribe) per doc. Maps doc id to the subscribe count. - model.root._subscribedDocs = new SubscribedDocs(); - - // Maps doc path to doc version - model.root._loadVersions = new LoadVersions(); -}); - -function FetchedDocs() {} -function SubscribedDocs() {} -function LoadVersions() {} - -Model.prototype.fetch = function() { - this._forSubscribable(arguments, 'fetch'); - return this; -}; -Model.prototype.unfetch = function() { - this._forSubscribable(arguments, 'unfetch'); - return this; -}; -Model.prototype.subscribe = function() { - this._forSubscribable(arguments, 'subscribe'); - return this; -}; -Model.prototype.unsubscribe = function() { - this._forSubscribable(arguments, 'unsubscribe'); - return this; -}; - -Model.prototype._forSubscribable = function(argumentsObject, method) { - var args, cb; - if (!argumentsObject.length) { - // Use this model's scope if no arguments - args = [null]; - } else if (typeof argumentsObject[0] === 'function') { - // Use this model's scope if the first argument is a callback - args = [null]; - cb = argumentsObject[0]; - } else if (Array.isArray(argumentsObject[0])) { - // Items can be passed in as an array - args = argumentsObject[0]; - cb = argumentsObject[1]; - } else { - // Or as multiple arguments - args = Array.prototype.slice.call(argumentsObject); - var last = args[args.length - 1]; - if (typeof last === 'function') cb = args.pop(); - } - - var group = util.asyncGroup(this.wrapCallback(cb)); - var finished = group(); - var docMethod = method + 'Doc'; - - for (var i = 0; i < args.length; i++) { - var item = args[i]; - if (item instanceof Query) { - item[method](group()); - } else { - var segments = this._dereference(this._splitPath(item)); - if (segments.length === 2) { - // Do the appropriate method for a single document. - this[docMethod](segments[0], segments[1], group()); - } else if (segments.length === 1) { - // Make a query to an entire collection. - var query = this.query(segments[0], {}); - query[method](group()); - } else if (segments.length === 0) { - group()(new Error('No path specified for ' + method)); - } else { - group()(new Error('Cannot ' + method + ' to a path within a document: ' + - segments.join('.'))); - } - } - } - process.nextTick(finished); -}; - -/** - * @param {String} - * @param {String} id - * @param {Function} cb(err) - * @param {Boolean} alreadyLoaded - */ -Model.prototype.fetchDoc = function(collectionName, id, cb, alreadyLoaded) { - cb = this.wrapCallback(cb); - - // Maintain a count of fetches so that we can unload the document when - // there are no remaining fetches or subscribes for that document - var path = collectionName + '.' + id; - this._context.fetchDoc(path, this._pass); - this.root._fetchedDocs[path] = (this.root._fetchedDocs[path] || 0) + 1; - - var model = this; - var doc = this.getOrCreateDoc(collectionName, id); - if (alreadyLoaded) { - fetchDocCallback(); - } else { - doc.shareDoc.fetch(fetchDocCallback); - } - function fetchDocCallback(err) { - if (err) return cb(err); - if (doc.shareDoc.version !== model.root._loadVersions[path]) { - model.root._loadVersions[path] = doc.shareDoc.version; - doc._updateCollectionData(); - model.emit('load', [collectionName, id], [doc.get(), model._pass]); - } - cb(); - } -}; - -/** - * @param {String} collectionName - * @param {String} id of the document we want to subscribe to - * @param {Function} cb(err) - */ -Model.prototype.subscribeDoc = function(collectionName, id, cb) { - cb = this.wrapCallback(cb); - - var path = collectionName + '.' + id; - this._context.subscribeDoc(path, this._pass); - var count = this.root._subscribedDocs[path] = (this.root._subscribedDocs[path] || 0) + 1; - // Already requested a subscribe, so just return - if (count > 1) return cb(); - - // Subscribe if currently unsubscribed - var model = this; - var doc = this.getOrCreateDoc(collectionName, id); - if (this.root.fetchOnly) { - // Only fetch if the document isn't already loaded - if (doc.get() === void 0) { - doc.shareDoc.fetch(subscribeDocCallback); - } else { - subscribeDocCallback(); - } - } else { - doc.shareDoc.subscribe(subscribeDocCallback); - } - function subscribeDocCallback(err) { - if (err) return cb(err); - if (!doc.createdLocally && doc.shareDoc.version !== model.root._loadVersions[path]) { - model.root._loadVersions[path] = doc.shareDoc.version; - doc._updateCollectionData(); - model.emit('load', [collectionName, id], [doc.get(), model._pass]); - } - cb(); - } -}; - -Model.prototype.unfetchDoc = function(collectionName, id, cb) { - cb = this.wrapCallback(cb); - var path = collectionName + '.' + id; - this._context.unfetchDoc(path, this._pass); - var fetchedDocs = this.root._fetchedDocs; - - // No effect if the document has no fetch count - if (!fetchedDocs[path]) return cb(); - - var model = this; - if (this.root.unloadDelay && !this._pass.$query) { - setTimeout(finishUnfetchDoc, this.root.unloadDelay); - } else { - finishUnfetchDoc(); - } - function finishUnfetchDoc() { - var count = --fetchedDocs[path]; - if (count) return cb(null, count); - delete fetchedDocs[path]; - model._maybeUnloadDoc(collectionName, id, path); - cb(null, 0); - } -}; - -Model.prototype.unsubscribeDoc = function(collectionName, id, cb) { - cb = this.wrapCallback(cb); - var path = collectionName + '.' + id; - this._context.unsubscribeDoc(path, this._pass); - var subscribedDocs = this.root._subscribedDocs; - - // No effect if the document is not currently subscribed - if (!subscribedDocs[path]) return cb(); - - var model = this; - if (this.root.unloadDelay && !this._pass.$query) { - setTimeout(finishUnsubscribeDoc, this.root.unloadDelay); - } else { - finishUnsubscribeDoc(); - } - function finishUnsubscribeDoc() { - var count = --subscribedDocs[path]; - // If there are more remaining subscriptions, only decrement the count - // and callback with how many subscriptions are remaining - if (count) return cb(null, count); - - // If there is only one remaining subscription, actually unsubscribe - delete subscribedDocs[path]; - if (model.root.fetchOnly) { - unsubscribeDocCallback(); - } else { - var shareDoc = model.root.shareConnection.get(collectionName, id); - if (!shareDoc) { - return cb(new Error('Share document not found for: ' + path)); - } - shareDoc.unsubscribe(unsubscribeDocCallback); - } - } - function unsubscribeDocCallback(err) { - model._maybeUnloadDoc(collectionName, id, path); - if (err) return cb(err); - cb(null, 0); - } -}; - -/** - * Removes the document from the local model if the model no longer has any - * remaining fetches or subscribes on path. - * Called from Model.prototype.unfetchDoc and Model.prototype.unsubscribeDoc as - * part of attempted cleanup. - * @param {String} collectionName - * @param {String} id - * @param {String} path - */ -Model.prototype._maybeUnloadDoc = function(collectionName, id, path) { - var doc = this.getDoc(collectionName, id); - if (!doc) return; - // Remove the document from the local model if it no longer has any - // remaining fetches or subscribes - if (this.root._fetchedDocs[path] || this.root._subscribedDocs[path]) return; - var previous = doc.get(); - this.root.collections[collectionName].remove(id); - - // TODO: There is a bug in ShareJS where a race condition between subscribe - // and destroying the document data. For now, not cleaning up ShareJS docs - // if (doc.shareDoc) doc.shareDoc.destroy(); - - delete this.root._loadVersions[path]; - this.emit('unload', [collectionName, id], [previous, this._pass]); -}; diff --git a/lib/Model/unbundle.js b/lib/Model/unbundle.js deleted file mode 100644 index ce1f43c45..000000000 --- a/lib/Model/unbundle.js +++ /dev/null @@ -1,57 +0,0 @@ -var Model = require('./Model'); - -Model.prototype.unbundle = function(data) { - // Re-create and subscribe queries; re-create documents associated with queries - this._initQueries(data.queries); - - // Re-create other documents - for (var collectionName in data.collections) { - var collection = data.collections[collectionName]; - for (var id in collection) { - var doc = this.getOrCreateDoc(collectionName, id, collection[id]); - if (doc.shareDoc) { - this._loadVersions[collectionName + '.' + id] = doc.shareDoc.version; - } - } - } - - for (var contextId in data.contexts) { - var contextData = data.contexts[contextId]; - var contextModel = this.context(contextId); - // Re-init fetchedDocs counts - for (var path in contextData.fetchedDocs) { - contextModel._context.fetchDoc(path, contextModel._pass); - this._fetchedDocs[path] = (this._fetchedDocs[path] || 0) + - contextData.fetchedDocs[path]; - } - // Subscribe to document subscriptions - for (var path in contextData.subscribedDocs) { - var subscribed = contextData.subscribedDocs[path]; - while (subscribed--) { - contextModel.subscribe(path); - } - } - } - - // Re-create refs - for (var i = 0; i < data.refs.length; i++) { - var item = data.refs[i]; - this.ref(item[0], item[1]); - } - // Re-create refLists - for (var i = 0; i < data.refLists.length; i++) { - var item = data.refLists[i]; - this.refList(item[0], item[1], item[2], item[3]); - } - // Re-create fns - for (var i = 0; i < data.fns.length; i++) { - var item = data.fns[i]; - this.start.apply(this, item); - } - // Re-create filters - for (var i = 0; i < data.filters.length; i++) { - var item = data.filters[i]; - var filter = this._filters.add(item[1], item[2], item[3], item[4]); - filter.ref(item[0]); - } -}; diff --git a/lib/Racer.js b/lib/Racer.js deleted file mode 100644 index 95f778d73..000000000 --- a/lib/Racer.js +++ /dev/null @@ -1,30 +0,0 @@ -var EventEmitter = require('events').EventEmitter; -var Model = require('./Model'); -var util = require('./util'); - -module.exports = Racer; - -function Racer() { - EventEmitter.call(this); -} - -util.mergeInto(Racer.prototype, EventEmitter.prototype); - -// Make classes accessible for use by plugins and tests -Racer.prototype.Model = Model; -Racer.prototype.util = util; - -// Support plugins on racer instances -Racer.prototype.use = util.use; -Racer.prototype.serverUse = util.serverUse; - -Racer.prototype.createModel = function(data) { - var model = new Model(); - if (data) { - model.createConnection(data); - model.unbundle(data); - } - return model; -}; - -util.serverRequire(module, './Racer.server'); diff --git a/lib/Racer.server.js b/lib/Racer.server.js deleted file mode 100644 index c82953357..000000000 --- a/lib/Racer.server.js +++ /dev/null @@ -1,11 +0,0 @@ -var Store = require('./Store'); -var Racer = require('./Racer'); - -Racer.prototype.Store = Store; -Racer.prototype.version = require('../package').version; - -Racer.prototype.createStore = function(options) { - var store = new Store(this, options); - this.emit('store', store); - return store; -}; diff --git a/lib/Store.js b/lib/Store.js deleted file mode 100644 index 49eff940d..000000000 --- a/lib/Store.js +++ /dev/null @@ -1,82 +0,0 @@ -var Duplex = require('stream').Duplex; -var EventEmitter = require('events').EventEmitter; -var share = require('share'); -var util = require('./util'); -var Channel = require('./Channel'); -var Model = require('./Model'); - -module.exports = Store; - -function Store(racer, options) { - EventEmitter.call(this); - this.racer = racer; - this.modelOptions = options && options.modelOptions; - this.shareClient = options && share.server.createClient(options); - this.logger = options && options.logger; - this.on('client', function(client) { - var socket = new ClientSocket(client); - client.channel = new Channel(socket); - }); - this.on('bundle', function(browserify) { - browserify.require(__dirname + '/index.js', {expose: 'racer'}); - }); -} - -util.mergeInto(Store.prototype, EventEmitter.prototype); - -Store.prototype.use = util.use; - -Store.prototype.createModel = function(options, req) { - if (this.modelOptions) { - options = (options) ? - util.mergeInto(options, this.modelOptions) : - this.modelOptions; - } - var model = new Model(options); - this.emit('model', model); - var stream = new Duplex({objectMode: true}); - stream.isServer = true; - this.emit('modelStream', stream); - - model.createConnection(stream, this.logger); - this.shareClient.listen(stream, req); - - return model; -}; - -Store.prototype.modelMiddleware = function() { - var store = this; - function modelMiddleware(req, res, next) { - var model; - - function getModel() { - if (model) return model; - model = store.createModel({fetchOnly: true}, req); - return model; - } - req.getModel = getModel; - - function closeModel() { - res.removeListener('finish', closeModel); - res.removeListener('close', closeModel); - model && model.close(); - } - res.on('finish', closeModel); - res.on('close', closeModel); - - next(); - } - return modelMiddleware; -}; - -function ClientSocket(client) { - this.client = client; - var socket = this; - client.on('message', function(message) { - socket.onmessage({type:'message', data:message}); - }); -} -ClientSocket.prototype.send = function(data) { - if (typeof data !== 'string') data = JSON.stringify(data); - this.client.send(data); -}; diff --git a/lib/index.js b/lib/index.js deleted file mode 100644 index 00dc928d3..000000000 --- a/lib/index.js +++ /dev/null @@ -1,2 +0,0 @@ -var Racer = require('./Racer'); -module.exports = new Racer(); diff --git a/lib/util.js b/lib/util.js deleted file mode 100644 index f43f50de0..000000000 --- a/lib/util.js +++ /dev/null @@ -1,180 +0,0 @@ -var deepIs = require('deep-is'); - -var isServer = process.title !== 'browser'; -exports.isServer = isServer; - -exports.asyncGroup = asyncGroup; -exports.castSegments = castSegments; -exports.contains = contains; -exports.copy = copy; -exports.copyObject = copyObject; -exports.deepCopy = deepCopy; -exports.deepEqual = deepIs; -exports.equal = equal; -exports.equalsNaN = equalsNaN; -exports.isArrayIndex = isArrayIndex; -exports.lookup = lookup; -exports.mergeInto = mergeInto; -exports.mayImpact = mayImpact; -exports.mayImpactAny = mayImpactAny; -exports.serverRequire = serverRequire; -exports.serverUse = serverUse; -exports.use = use; - -function asyncGroup(cb) { - var group = new AsyncGroup(cb); - return function asyncGroupAdd() { - return group.add(); - }; -} - -/** - * @constructor - * @param {Function} cb(err) - */ -function AsyncGroup(cb) { - this.cb = cb; - this.isDone = false; - this.count = 0; -} -AsyncGroup.prototype.add = function() { - this.count++; - var self = this; - return function(err) { - self.count--; - if (self.isDone) return; - if (err) { - self.isDone = true; - self.cb(err); - return; - } - if (self.count > 0) return; - self.isDone = true; - self.cb(); - }; -}; - -function castSegments(segments) { - // Cast number path segments from strings to numbers - for (var i = segments.length; i--;) { - var segment = segments[i]; - if (typeof segment === 'string' && isArrayIndex(segment)) { - segments[i] = +segment; - } - } - return segments; -} - -function contains(segments, testSegments) { - for (var i = 0; i < segments.length; i++) { - if (segments[i] !== testSegments[i]) return false; - } - return true; -} - -function copy(value) { - if (value instanceof Date) return new Date(value); - if (typeof value === 'object') { - if (value === null) return null; - if (Array.isArray(value)) return value.slice(); - return copyObject(value); - } - return value; -} - -function copyObject(object) { - var out = new object.constructor(); - for (var key in object) { - if (object.hasOwnProperty(key)) { - out[key] = object[key]; - } - } - return out; -} - -function deepCopy(value) { - if (value instanceof Date) return new Date(value); - if (typeof value === 'object') { - if (value === null) return null; - if (Array.isArray(value)) { - var array = []; - for (var i = value.length; i--;) { - array[i] = deepCopy(value[i]); - } - return array; - } - var object = new value.constructor(); - for (var key in value) { - if (value.hasOwnProperty(key)) { - object[key] = deepCopy(value[key]); - } - } - return object; - } - return value; -} - -function equal(a, b) { - return (a === b) || (equalsNaN(a) && equalsNaN(b)); -} - -function equalsNaN(x) { - return x !== x; -} - -function isArrayIndex(segment) { - return (/^[0-9]+$/).test(segment); -} - -function lookup(segments, value) { - if (!segments) return value; - - for (var i = 0, len = segments.length; i < len; i++) { - if (value == null) return value; - value = value[segments[i]]; - } - return value; -} - -function mayImpactAny(segmentsList, testSegments) { - for (var i = 0, len = segmentsList.length; i < len; i++) { - if (mayImpact(segmentsList[i], testSegments)) return true; - } - return false; -} - -function mayImpact(segments, testSegments) { - var len = Math.min(segments.length, testSegments.length); - for (var i = 0; i < len; i++) { - if (segments[i] !== testSegments[i]) return false; - } - return true; -} - -function mergeInto(to, from) { - for (var key in from) { - to[key] = from[key]; - } - return to; -} - -function serverRequire(module, id) { - if (!isServer) return; - return module.require(id); -} - -function serverUse(module, id, options) { - if (!isServer) return this; - var plugin = module.require(id); - return this.use(plugin, options); -} - -function use(plugin, options) { - // Don't include a plugin more than once - var plugins = this._plugins || (this._plugins = []); - if (plugins.indexOf(plugin) === -1) { - plugins.push(plugin); - plugin(this, options); - } - return this; -} diff --git a/package.json b/package.json index 7ec0bf9ce..c06850b5b 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,58 @@ { "name": "racer", "description": "Realtime model synchronization engine for Node.js", - "homepage": "http://racerjs.com/", + "homepage": "https://github.com/derbyjs/racer", "repository": { "type": "git", - "url": "git://github.com/codeparty/racer.git" + "url": "git://github.com/derbyjs/racer.git" }, - "version": "0.6.0-alpha21", + "publishConfig": { + "access": "public" + }, + "version": "2.3.0", "main": "./lib/index.js", + "files": [ + "lib/*" + ], "scripts": { - "test": "./node_modules/.bin/mocha test/**/*.mocha.coffee && ./node_modules/.bin/jshint lib/*.js lib/**/*.js" + "build": "node_modules/.bin/tsc", + "docs": "node_modules/.bin/typedoc", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "pretest": "npm run build", + "test": "node_modules/.bin/mocha", + "checks": "npm run lint && npm test", + "prepare": "npm run build", + "test-cover": "node_modules/nyc/bin/nyc.js --temp-dir=coverage -r text -r lcov node_modules/mocha/bin/_mocha" }, + "types": "./lib/index.d.ts", "dependencies": { - "arraydiff": "~0.1.1", - "deep-is": "~0.1.1", - "node-uuid": "~1.4.1", - "share": "~0.7.1" + "arraydiff": "^0.1.1", + "fast-deep-equal": "^2.0.1", + "sharedb": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0", + "util": "^0.12.5", + "uuid": "^2.0.1" }, "devDependencies": { - "coffee-script": "~1.7.1", - "expect.js": "~0.3.1", - "jshint": "~2.4.4", - "mocha": "~1.17.1" + "@types/node": "^20.3.1", + "@types/sharedb": "^3.3.10", + "chai": "^4.2.0", + "coveralls": "^3.0.5", + "eslint": "^8.1.0", + "eslint-config-google": "^0.14.0", + "mocha": "^9.1.3", + "nyc": "^15.1.0", + "typedoc": "^0.26.5", + "typedoc-plugin-mdn-links": "^3.1.28", + "typedoc-plugin-missing-exports": "^3.0.0", + "typescript": "~5.4.5" + }, + "bugs": { + "url": "https://github.com/derbyjs/racer/issues" + }, + "directories": { + "test": "test" }, - "optionalDependencies": {}, - "engines": { - "node": ">=0.10.0" - } + "author": "Nate Smith", + "license": "MIT" } diff --git a/src/Backend.ts b/src/Backend.ts new file mode 100644 index 000000000..2282b34f4 --- /dev/null +++ b/src/Backend.ts @@ -0,0 +1,79 @@ +import * as path from 'path'; +import * as util from './util'; +import { type ModelOptions, RootModel } from './Model/Model'; +import Backend = require('sharedb'); + +export type BackendOptions = { modelOptions?: ModelOptions } & Backend.ShareDBOptions; + +/** + * RacerBackend extends ShareDb Backend + */ +export class RacerBackend extends Backend { + racer: any; + modelOptions: ModelOptions; + + /** + * + * @param racer - Racer instance + * @param options - Model and SharedB options + */ + constructor(racer: any, options?: BackendOptions) { + super(options); + this.racer = racer; + this.modelOptions = options && options.modelOptions; + this.on('bundle', function (browserify) { + var racerPath = path.join(__dirname, 'index.js'); + browserify.require(racerPath, { expose: 'racer' }); + }); + } + + /** + * Create new `RootModel` + * + * @param options - Optional model options + * @param request - Optional request context See {@link Backend.listen} for details. + * @returns a new root model + */ + createModel(options?: ModelOptions, request?: any) { + if (this.modelOptions) { + options = (options) ? + util.mergeInto(options, this.modelOptions) : + this.modelOptions; + } + var model = new RootModel(options); + this.emit('model', model); + model.createConnection(this, request); + return model; + }; + + /** + * Model middleware that creates and attaches a {@link Model} to the `request` + * and attaches listeners to response for closing model on response completion + * + * @returns an Express middleware function + */ + modelMiddleware() { + var backend = this; + function modelMiddleware(req, res, next) { + // Do not add a model to the request if one has been added already + if (req.model) return next(); + + // Create a new model for this request + req.model = backend.createModel({ fetchOnly: true }, req); + + // Close the model when this request ends + function closeModel() { + res.removeListener('finish', closeModel); + res.removeListener('close', closeModel); + if (req.model) req.model.close(); + } + res.on('finish', closeModel); + res.on('close', closeModel); + + next(); + } + return modelMiddleware; + }; +} + +function getModelUndefined() { } diff --git a/src/Model/CollectionCounter.ts b/src/Model/CollectionCounter.ts new file mode 100644 index 000000000..6db724810 --- /dev/null +++ b/src/Model/CollectionCounter.ts @@ -0,0 +1,82 @@ +export class CollectionCounter { + collections: Record; + size: number; + + constructor() { + this.reset(); + } + + reset() { + // A map of CounterMaps + this.collections = {}; + // The number of id keys in the collections map + this.size = 0; + }; + + get(collectionName: string, id: string) { + var collection = this.collections[collectionName]; + return (collection && collection.counts[id]) || 0; + }; + + increment(collectionName: string, id: string) { + var collection = this.collections[collectionName]; + if (!collection) { + collection = this.collections[collectionName] = new CounterMap(); + this.size++; + } + return collection.increment(id); + }; + + decrement(collectionName: string, id: string) { + var collection = this.collections[collectionName]; + if (!collection) return 0; + var count = collection.decrement(id); + if (collection.size < 1) { + delete this.collections[collectionName]; + this.size--; + } + return count; + }; + + toJSON() { + // Serialize to the contained count data if any + if (this.size > 0) { + var out = {}; + for (var collectionName in this.collections) { + out[collectionName] = this.collections[collectionName].counts; + } + return out; + } + return; + }; +} + +export class CounterMap { + counts: Record; + size: number; + + constructor() { + this.counts = {}; + this.size = 0; + } + + increment(key: string): number { + var count = this.counts[key] || 0; + if (count === 0) { + this.size++; + } + return this.counts[key] = count + 1; + }; + + decrement(key: string): number { + var count = this.counts[key] || 0; + if (count > 1) { + return this.counts[key] = count - 1; + } + if (count === 1) { + delete this.counts[key]; + this.size--; + } + return 0; + }; +} diff --git a/src/Model/CollectionMap.ts b/src/Model/CollectionMap.ts new file mode 100644 index 000000000..08d21e30d --- /dev/null +++ b/src/Model/CollectionMap.ts @@ -0,0 +1,38 @@ +import { FastMap } from './FastMap'; +import { Collection } from './collections'; + +export class CollectionMap{ + collections: Record>; + + constructor() { + // A map of collection names to FastMaps + this.collections = {}; + } + + getCollection(collectionName) { + var collection = this.collections[collectionName]; + return (collection && collection.values); + }; + + get(collectionName, id) { + var collection = this.collections[collectionName]; + return (collection && collection.values[id]); + }; + + set(collectionName, id, value) { + var collection = this.collections[collectionName]; + if (!collection) { + collection = this.collections[collectionName] = new FastMap(); + } + collection.set(id, value); + }; + + del(collectionName, id) { + var collection = this.collections[collectionName]; + if (collection) { + collection.del(id); + if (collection.size > 0) return; + delete this.collections[collectionName]; + } + }; +} diff --git a/src/Model/Doc.ts b/src/Model/Doc.ts new file mode 100644 index 000000000..bec5ebf15 --- /dev/null +++ b/src/Model/Doc.ts @@ -0,0 +1,30 @@ +import { type Model } from './Model'; +import { Collection } from './collections'; +import { Path } from '../types'; + +export class Doc { + collectionData: Model; + collectionName: string; + data: any; + id: string; + model: Model; + + constructor(model: Model, collectionName: string, id: string, data?: any, _collection?: Collection) { + this.collectionName = collectionName; + this.id = id; + this.data = data; + this.model = model; + this.collectionData = model && model.data[collectionName]; + } + + path(segments?: Path[]) { + var path = this.collectionName + '.' + this.id; + if (segments && segments.length) path += '.' + segments.join('.'); + return path; + }; + + _errorMessage(description: string, segments: Path[], value: any) { + return description + ' at ' + this.path(segments) + ': ' + + JSON.stringify(value, null, 2); + }; +} diff --git a/src/Model/EventListenerTree.ts b/src/Model/EventListenerTree.ts new file mode 100644 index 000000000..1fe79e6bd --- /dev/null +++ b/src/Model/EventListenerTree.ts @@ -0,0 +1,330 @@ +import { type Segments } from '../types'; +import { FastMap } from './FastMap'; + +/** + * Construct a tree root when invoked without any arguments. Children nodes are + * constructred internally as needed on calls to addListener() + * + * @param {EventListenerTree} [parent] + * @param {string} [segment] + */ +export class EventListenerTree { + parent?: any; + segment?: string; + children: any; + listeners: any; + + constructor(parent?: any, segment?: string) { + this.parent = parent; + this.segment = segment; + this.children = null; + this.listeners = null; + } + /** + * Remove the reference to this node from its parent so that it can be garbage + * collected. This is called internally when all listeners to a node + * are removed + */ + destroy() { + // For all non-root nodes, remove the reference to the node + var parent = this.parent; + if (parent) { + // Remove reference to this node from its parent + var children = parent.children; + if (children) { + children.del(this.segment); + if (children.size > 0) return; + parent.children = null; + } + // Destroy parent if it no longer has any dependents + if (!parent.listeners) { + parent.destroy(); + } + return; + } + // For the root node, reset any references to listeners or children + this.children = null; + this.listeners = null; + }; + + /** + * Get a node for a path if it exists + * + * @param {string[]} segments + * @return {EventListenerTree|undefined} + */ + _getChild(segments: Segments) { + var node = this; + for (var i = 0, len = segments.length; i < len; i++) { + var children = node.children; + if (!children) return; + var segment = segments[i]; + node = children.values[segment]; + if (!node) return; + } + return node; + }; + + /** + * If a path already has a node, return it. Otherwise, create the node and + * ancestors in a lazy manner. Return the node for the path + * + * @param {string[]} segments + * @return {EventListenerTree} + */ + _getOrCreateChild(segments: Segments) { + var node = this; + for (var i = 0, len = segments.length; i < len; i++) { + var children = node.children; + if (!children) { + children = node.children = new FastMap(); + } + var segment = segments[i]; + var next = children.values[segment]; + if (next) { + node = next; + } else { + // @ts-ignore + node = new EventListenerTree(node, segment); + children.set(segment, node); + } + } + return node; + }; + + /** + * Add a listener to a path location. Listener should be unique per path + * location, and calling twice with the same segments and listener value has no + * effect. Unlike EventEmitter, listener may be any type of value + * + * @param {string[]} segments + * @param {*} listener + * @return {EventListenerTree} + */ + addListener(segments: Segments, listener) { + var node = this._getOrCreateChild(segments); + var listeners = node.listeners; + if (listeners) { + var i = listeners.indexOf(listener); + if (i === -1) { + listeners.push(listener); + } + } else { + node.listeners = [listener]; + } + return node; + }; + + /** + * Remove a listener from a path location + * + * @param {string[]} segments + * @param {*} listener + */ + removeListener(segments: Segments, listener) { + var node = this._getChild(segments); + if (node) { + node.removeOwnListener(listener); + } + }; + + /** + * Remove a listener from the current node + * + * @param {*} listener + */ + removeOwnListener(listener) { + var listeners = this.listeners; + if (!listeners) return; + if (listeners.length === 1) { + if (listeners[0] === listener) { + this.listeners = null; + if (!this.children) { + this.destroy(); + } + } + return; + } + var i = listeners.indexOf(listener); + if (i > -1) { + listeners.splice(i, 1); + } + }; + + /** + * Remove all listeners and descendent listeners for a path location + * + * @param {string[]} segments + */ + removeAllListeners(segments: Segments) { + var node = this._getChild(segments); + if (node) { + node.destroy(); + } + }; + + /** + * Return direct listeners to `segments` + * + * @param {string[]} segments + * @return {Array} listeners + */ + getListeners(segments: Segments) { + var node = this._getChild(segments); + return (node && node.listeners) ? node.listeners.slice() : []; + }; + + /** + * Return an array with each of the listeners that may be affected by a change + * to `segments`. These are: + * 1. Listeners to each node from the root to the node for `segments` + * 2. Listeners to all descendent nodes under `segments` + * + * @param {string[]} segments + * @return {Array} listeners + */ + getAffectedListeners(segments: Segments) { + var listeners = []; + var node = pushAncestorListeners(listeners, segments, this); + if (node) { + pushDescendantListeners(listeners, node); + } + return listeners; + }; + + /** + * Return an array with each of the listeners to descendent nodes, not + * including listeners to `segments` itself + * + * @param {string[]} segments + * @return {Array} listeners + */ + getDescendantListeners(segments: Segments) { + var listeners = []; + var node = this._getChild(segments); + if (node) { + pushDescendantListeners(listeners, node); + } + return listeners; + }; + + /** + * Return an array with each of the listeners to descendent nodes, not + * including listeners to this node itself + * + * @return {Array} listeners + */ + getOwnDescendantListeners() { + var listeners = []; + pushDescendantListeners(listeners, this); + return listeners; + }; + + /** + * Return an array with each of the listeners to `segments`, including + * treating wildcard segments ('*') and remainder segments ('**') as matches + * + * @param {string[]} segments + * @return {Array} listeners + */ + getWildcardListeners(segments: Segments) { + var listeners = []; + pushWildcardListeners(listeners, this, segments, 0); + return listeners; + }; +} + +/** + * Push listeners matching `segments`, wildcards ('*'), and remainders ('**') + * onto passed in array. Start from segments index passed in for branching + * recursion without needing to modify segments array + * + * @param {Array} listeners + * @param {EventListenerTree} node + * @param {string[]} segments + * @param {integer} start + */ +function pushWildcardListeners(listeners, node, segments, start) { + for (var i = start, len = segments.length; i < len; i++) { + var children = node.children; + if (!children) return; + pushRemainderListeners(listeners, node); + var wildcardNode = children.values['*']; + if (wildcardNode) { + pushWildcardListeners(listeners, wildcardNode, segments, i + 1); + } + var segment = segments[i]; + node = children.values[segment]; + if (!node) return; + } + if (node.children) { + pushRemainderListeners(listeners, node); + } + pushListeners(listeners, node); +}; + +/** + * Push listeners to the '**' onto the passed in array + * + * @param {Array} listeners + * @param {EventListenerTree} node + */ +function pushRemainderListeners(listeners, node) { + var remainderNode = node.children.values['**']; + if (remainderNode) { + pushListeners(listeners, remainderNode); + } +} + +/** + * Push direct listeners onto the passed in array + * + * @param {Array} listeners + * @param {EventListenerTree} node + */ +function pushListeners(listeners, node) { + var nodeListeners = node.listeners; + if (!nodeListeners) return; + for (var i = 0, len = nodeListeners.length; i < len; i++) { + listeners.push(nodeListeners[i]); + } +} + +/** + * Push listeners for each ancestor node and the node at `segments` onto the + * passed in array. Return the node at `segments` if it exists + * + * @param {Array} listeners + * @param {string[]} segments + * @param {EventListenerTree} node + * @return {EventListenerTree|undefined} + */ +function pushAncestorListeners(listeners, segments, node) { + pushListeners(listeners, node); + for (var i = 0, len = segments.length; i < len; i++) { + var children = node.children; + if (!children) return; + var segment = segments[i]; + node = children.values[segment]; + if (!node) return; + pushListeners(listeners, node); + } + return node; +} + +/** + * Push listeners for each of the node's children and their recursive children + * onto the passed in array + * + * @param {Array} listeners + * @param {EventListenerTree} node + */ +function pushDescendantListeners(listeners, node) { + if (!node.children) return; + var values = node.children.values; + for (var key in values) { + var child = values[key]; + pushListeners(listeners, child); + pushDescendantListeners(listeners, child); + } +} diff --git a/src/Model/EventMapTree.ts b/src/Model/EventMapTree.ts new file mode 100644 index 000000000..f8172e693 --- /dev/null +++ b/src/Model/EventMapTree.ts @@ -0,0 +1,262 @@ +import { type Segments } from '../types'; +import { FastMap } from './FastMap'; + +/** + * Construct a tree root when invoked without any arguments. Children nodes are + * constructred internally as needed on calls to addListener() + * + * @param {EventMapTree} [parent] + * @param {string} [segment] + */ +export class EventMapTree { + parent?: EventMapTree; + segment?: string; + children: any; + listener: any; + + constructor(parent?: EventMapTree, segment?: string) { + this.parent = parent; + this.segment = segment; + this.children = null; + this.listener = null; + } + + /** + * Remove the reference to this node from its parent so that it can be garbage + * collected. This is called internally when all listener to a node + * are removed + */ + destroy() { + // For all non-root nodes, remove the reference to the node + var parent = this.parent; + if (parent) { + // Remove reference to this node from its parent + var children = parent.children; + if (children) { + children.del(this.segment); + if (children.size > 0) return; + parent.children = null; + } + // Destroy parent if it no longer has any dependents + if (parent.listener == null) { + parent.destroy(); + } + return; + } + // For the root node, reset any references to listener or children + this.children = null; + this.listener = null; + }; + + /** + * Get a node for a path if it exists + * + * @param {string[]} segments + * @return {EventMapTree|undefined} + */ + _getChild(segments: Segments) { + var node = this; + for (var i = 0, len = segments.length; i < len; i++) { + var children = node.children; + if (!children) return; + var segment = segments[i]; + node = children.values[segment]; + if (!node) return; + } + return node; + }; + + /** + * If a path already has a node, return it. Otherwise, create the node and + * parents in a lazy manner and return the node for the path + * + * @param {string[]} segments + * @return {EventMapTree} + */ + _getOrCreateChild(segments: Segments) { + var node: EventMapTree = this; + for (var i = 0, len = segments.length; i < len; i++) { + var children = node.children; + if (!children) { + children = node.children = new FastMap(); + } + var segment = segments[i]; + var next = children.values[segment]; + if (next) { + node = next; + } else { + node = new EventMapTree(node, segment.toString()); + children.set(segment, node); + } + } + return node; + }; + + /** + * Assign a listener to a path location. Listener may be any type of value. + * Return the previous listener value if any + * + * @param {string[]} segments + * @param {*} listener + * @return {*} previous + */ + setListener(segments: Segments, listener) { + var node = this._getOrCreateChild(segments); + var previous = node.listener; + node.listener = listener; + return previous; + }; + + /** + * Remove the listener at a path location and return it + * + * @param {string[]} segments + * @return {*} previous + */ + deleteListener(segments: Segments) { + var node = this._getChild(segments); + if (!node) return; + var previous = node.listener; + node.listener = null; + if (!node.children) { + node.destroy(); + } + return previous; + }; + + /** + * Remove all listeners and descendent listeners for a path location. Return the + * node for the path location if any + * + * @param {string[]} segments + * @return {EventMapTree} + */ + deleteAllListeners(segments: Segments) { + var node = this._getChild(segments); + if (node) { + node.destroy(); + } + return node; + }; + + /** + * Return the direct listener to `segments` if any + * + * @param {string[]} segments + * @return {*} listeners + */ + getListener(segments: Segments) { + var node = this._getChild(segments); + return (node) ? node.listener : null; + }; + + /** + * Return an array with each of the listeners that may be affected by a change + * to `segments`. These are: + * 1. Listeners to each node from the root to the node for `segments` + * 2. Listeners to all descendent nodes under `segments` + * + * @param {string[]} segments + * @return {Array} listeners + */ + getAffectedListeners(segments) { + var listeners = []; + var node = pushAncestorListeners(listeners, segments, this); + if (node) { + pushDescendantListeners(listeners, node); + } + return listeners; + }; + + /** + * Call the callback with each listener to the node and its decendants + * + * @param {EventMapTree} node + * @param {Function} callback + */ + forEach(callback) { + forListener(this, callback); + forDescendantListeners(this, callback); + }; +} + +/** + * Push node's direct listener onto the passed in array if not null + * + * @param {Array} listeners + * @param {EventMapTree} node + */ +function pushListener(listeners, node) { + if (node.listener != null) { + listeners.push(node.listener); + } +} + +/** + * Push listeners for each ancestor node and the node at `segments` onto the + * passed in array. Return the node at `segments` if it exists + * + * @param {Array} listeners + * @param {string[]} segments + * @param {EventMapTree} node + * @return {EventMapTree|undefined} + */ +function pushAncestorListeners(listeners, segments, node) { + pushListener(listeners, node); + for (var i = 0, len = segments.length; i < len; i++) { + var children = node.children; + if (!children) return; + var segment = segments[i]; + node = children.values[segment]; + if (!node) return; + pushListener(listeners, node); + } + return node; +} + +/** + * Push listeners for each of the node's children and their recursive children + * onto the passed in array + * + * @param {Array} listeners + * @param {EventMapTree} node + */ +function pushDescendantListeners(listeners, node) { + if (!node.children) return; + var values = node.children.values; + for (var key in values) { + var child = values[key]; + pushListener(listeners, child); + pushDescendantListeners(listeners, child); + } +} + +/** + * Call the callback with the node's direct listener if not null + * + * @param {EventMapTree} node + * @param {Function} callback + */ +function forListener(node, callback) { + var listener = node.listener; + if (listener != null) { + callback(listener); + } +} + +/** + * Call the callback with each listener value for each of the node's children + * and their recursive children + * + * @param {EventMapTree} node + * @param {Function} callback + */ +function forDescendantListeners(node, callback) { + if (!node.children) return; + var values = node.children.values; + for (var key in values) { + var child = values[key]; + forListener(child, callback); + forDescendantListeners(child, callback); + } +} diff --git a/src/Model/FastMap.ts b/src/Model/FastMap.ts new file mode 100644 index 000000000..3fccfa026 --- /dev/null +++ b/src/Model/FastMap.ts @@ -0,0 +1,24 @@ + +export class FastMap{ + values: Record; + size: number; + + constructor() { + this.values = {}; + this.size = 0; + } + + set(key: string, value: T) { + if (!(key in this.values)) { + this.size++; + } + return this.values[key] = value; + }; + + del(key: string) { + if (key in this.values) { + this.size--; + } + delete this.values[key]; + }; +} diff --git a/src/Model/LocalDoc.ts b/src/Model/LocalDoc.ts new file mode 100644 index 000000000..a57523c57 --- /dev/null +++ b/src/Model/LocalDoc.ts @@ -0,0 +1,221 @@ +import { type Model } from './Model'; +import { Doc } from './Doc'; +import { Callback, Path } from '../types'; +var util = require('../util'); + +export class LocalDoc extends Doc{ + constructor(model: Model, collectionName: string, id: string, data: any) { + super(model, collectionName, id, data); + this._updateCollectionData(); + } + + _updateCollectionData() { + this.collectionData[this.id] = this.data; + }; + + create(value: any, cb?: Callback) { + if (this.data !== undefined) { + var message = this._errorMessage('create on local document with data', null, this.data); + var err = new Error(message); + return cb(err); + } + this.data = value; + this._updateCollectionData(); + cb(); + }; + + set(segments: Path, value: any, cb?: Callback) { + function set(node, key) { + var previous = node[key]; + node[key] = value; + return previous; + } + return this._apply(segments, set, cb); + }; + + del(segments: Path, cb?: Callback) { + // Don't do anything if the value is already undefined, since + // apply creates objects as it traverses, and the del method + // should not create anything + var previous = this.get(segments); + if (previous === undefined) { + cb(); + return; + } + function del(node, key) { + delete node[key]; + return previous; + } + return this._apply(segments, del, cb); + }; + + increment(segments, byNumber, cb?: Callback) { + var self = this; + function validate(value) { + if (typeof value === 'number' || value == null) return; + return new TypeError(self._errorMessage( + 'increment on non-number', segments, value + )); + } + function increment(node, key) { + var value = (node[key] || 0) + byNumber; + node[key] = value; + return value; + } + return this._validatedApply(segments, validate, increment, cb); + }; + + push(segments: Path, value: unknown, cb?: Callback) { + function push(arr) { + return arr.push(value); + } + return this._arrayApply(segments, push, cb); + }; + + unshift(segments: Path, value: unknown, cb?: Callback) { + function unshift(arr) { + return arr.unshift(value); + } + return this._arrayApply(segments, unshift, cb); + }; + + insert(segments: Path, index: number, values, cb?: Callback) { + function insert(arr) { + arr.splice.apply(arr, [index, 0].concat(values)); + return arr.length; + } + return this._arrayApply(segments, insert, cb); + }; + + pop(segments: Path, cb?: Callback) { + function pop(arr) { + return arr.pop(); + } + return this._arrayApply(segments, pop, cb); + }; + + shift(segments: Path, cb?: Callback) { + function shift(arr) { + return arr.shift(); + } + return this._arrayApply(segments, shift, cb); + }; + + remove(segments: Path, index: number, howMany: number, cb?: Callback) { + function remove(arr) { + return arr.splice(index, howMany); + } + return this._arrayApply(segments, remove, cb); + }; + + move(segments, from, to, howMany, cb?: Callback) { + function move(arr) { + // Remove from old location + var values = arr.splice(from, howMany); + // Insert in new location + arr.splice.apply(arr, [to, 0].concat(values)); + return values; + } + return this._arrayApply(segments, move, cb); + }; + + stringInsert(segments, index, value, cb?: Callback) { + var self = this; + function validate(value) { + if (typeof value === 'string' || value == null) return; + return new TypeError(self._errorMessage( + 'stringInsert on non-string', segments, value + )); + } + function stringInsert(node, key) { + var previous = node[key]; + if (previous == null) { + node[key] = value; + return previous; + } + node[key] = previous.slice(0, index) + value + previous.slice(index); + return previous; + } + return this._validatedApply(segments, validate, stringInsert, cb); + }; + + stringRemove(segments: Path[], index: number, howMany: number, cb?: Callback) { + var self = this; + function validate(value) { + if (typeof value === 'string' || value == null) return; + return new TypeError(self._errorMessage( + 'stringRemove on non-string', segments, value + )); + } + function stringRemove(node, key) { + var previous = node[key]; + if (previous == null) return previous; + if (index < 0) index += previous.length; + node[key] = previous.slice(0, index) + previous.slice(index + howMany); + return previous; + } + return this._validatedApply(segments, validate, stringRemove, cb); + }; + + get(segments?: Path) { + return util.lookup(segments, this.data); + }; + + /** + * @param {Array} segments is the array representing a path + * @param {Function} fn(node, key) applies a mutation on node[key] + * @return {Object} returns the return value of fn(node, key) + */ + _createImplied(segments, fn) { + var node = this; + var key = 'data'; + var i = 0; + var nextKey = segments[i++]; + while (nextKey != null) { + // Get or create implied object or array + node = node[key] || (node[key] = /^\d+$/.test(nextKey) ? [] : {}); + key = nextKey; + nextKey = segments[i++]; + } + return fn(node, key); + }; + + _apply(segments, fn, cb?: Callback) { + var out = this._createImplied(segments, fn); + this._updateCollectionData(); + cb(); + return out; + }; + + _validatedApply(segments, validate, fn, cb?: Callback) { + var out = this._createImplied(segments, function(node, key) { + var err = validate(node[key]); + if (err) return cb(err); + return fn(node, key); + }); + this._updateCollectionData(); + cb(); + return out; + }; + + _arrayApply(segments, fn, cb?: Callback) { + // Lookup a pointer to the property or nested property & + // return the current value or create a new array + var arr = this._createImplied(segments, nodeCreateArray); + + if (!Array.isArray(arr)) { + var message = this._errorMessage(fn.name + ' on non-array', segments, arr); + var err = new TypeError(message); + return cb(err); + } + var out = fn(arr); + this._updateCollectionData(); + cb(); + return out; + }; +} + +function nodeCreateArray(node, key) { + var node = node[key] || (node[key] = []); + return node; +} diff --git a/src/Model/Model.ts b/src/Model/Model.ts new file mode 100644 index 000000000..675a328a2 --- /dev/null +++ b/src/Model/Model.ts @@ -0,0 +1,120 @@ +import { v4 as uuidv4 } from 'uuid'; +import { type Context } from './contexts'; +import { RacerBackend } from '../Backend'; +import { type Connection } from './connection'; +import { type ModelData } from './collections'; +import { Primitive } from '../types'; + +export type UUID = string; + +export type DefualtType = unknown; + +declare module './Model' { + interface DebugOptions { + /** Enables browser side logging of ShareDB operations */ + debugMutations?: boolean, + /** Disable submitting of local operations to remote backend */ + disableSubmit?: boolean, + remoteMutations?: boolean, + } + + interface ModelOptions { + /** see {@link DebugOptions} */ + debug?: DebugOptions; + /** Ensure read-only access of model data */ + fetchOnly?: boolean; + /** + * Delay in milliseconds before actually unloading data after `unload` called + * + * Default to 0 on server, and 1000ms for browser. Runtime value can be inspected + * on {@link RootModel.unloadDelay} + */ + unloadDelay?: number; + bundleTimeout?: number; + } + + type ErrorCallback = (err?: Error) => void; +} + +type ModelInitFunction = (instance: RootModel, options: ModelOptions) => void; + +/** + * Base class for Racer models + * + * @typeParam T - Type of data the Model contains + */ +export class Model { + static INITS: ModelInitFunction[] = []; + + ChildModel = ChildModel; + debug: DebugOptions; + root: RootModel; + data: T; + + _at: string; + _context: Context; + _eventContext: number | null; + _events: []; + _maxListeners: number; + _pass: any; + _preventCompose: boolean; + _silent: boolean; + + /** + * Creates a new Racer UUID + * + * @returns a new Racer UUID. + * */ + id(): UUID { + return uuidv4(); + } + + _child() { + return new ChildModel(this); + }; +} + +/** + * RootModel is the model that holds all data and maintains connection info + */ +export class RootModel extends Model { + backend: RacerBackend; + connection: Connection; + + constructor(options: ModelOptions = {}) { + super(); + this.root = this; + var inits = Model.INITS; + this.debug = options.debug || {}; + for (var i = 0; i < inits.length; i++) { + inits[i](this, options); + } + } +} + +/** + * Model for some subset of the data + * + * @typeParam T - type of data the ChildModel contains. + */ +export class ChildModel extends Model { + constructor(model: Model) { + super(); + // Shared properties should be accessed via the root. This makes inheritance + // cheap and easily extensible + this.root = model.root; + + // EventEmitter methods access these properties directly, so they must be + // inherited manually instead of via the root + this._events = model._events; + this._maxListeners = model._maxListeners; + + // Properties specific to a child instance + this._context = model._context; + this._at = model._at; + this._pass = model._pass; + this._silent = model._silent; + this._eventContext = model._eventContext; + this._preventCompose = model._preventCompose; + } +} diff --git a/lib/Model/ModelStandalone.js b/src/Model/ModelStandalone.ts similarity index 100% rename from lib/Model/ModelStandalone.js rename to src/Model/ModelStandalone.ts diff --git a/src/Model/Query.ts b/src/Model/Query.ts new file mode 100644 index 000000000..5e355190d --- /dev/null +++ b/src/Model/Query.ts @@ -0,0 +1,619 @@ +import { type Context } from './contexts'; +import { type Segments } from '../types'; +import { ChildModel, ErrorCallback, Model } from './Model'; +import { CollectionMap } from './CollectionMap'; +import { ModelData } from '.'; +import type { Doc as ShareDBDoc } from 'sharedb'; +import type { RemoteDoc } from './RemoteDoc'; + +var defaultType = require('sharedb/lib/client').types.defaultType; +var util = require('../util'); +var promisify = util.promisify; + +export type QueryOptions = string | { [key: string]: unknown }; + +interface QueryCtor { + new (model: Model, collectionName: string, expression: any, options: QueryOptions): Query; +} + +declare module './Model' { + interface Model { + query(collectionName: string, expression, options?: QueryOptions): Query; + sanitizeQuery(expression: any): any; + + _getOrCreateQuery(collectionName: string, expression, options: QueryOptions, QueryConstructor: QueryCtor): Query; + _initQueries(items: any[]): void; + _queries: Queries; + } +} + +Model.INITS.push(function(model) { + model.root._queries = new Queries(); +}); + +Model.prototype.query = function(collectionName: string, expression, options?: QueryOptions) { + // DEPRECATED: Passing in a string as the third argument specifies the db + // option for backward compatibility + if (typeof options === 'string') { + options = { db: options }; + } + return this._getOrCreateQuery(collectionName, expression, options, Query); +}; + +/** + * If an existing query is present with the same context, `collectionName`, + * `expression`, and `options`, then returns the existing query; otherwise, + * constructs and returns a new query using `QueryConstructor`. + * + * @param {string} collectionName + * @param {*} expression + * @param {*} options + * @param {new (model: Model, collectionName: string, expression: any, options: any) => Query} QueryConstructor - + * constructor function for a Query, to create one if not already present on this model + */ +Model.prototype._getOrCreateQuery = function(collectionName, expression, options, QueryConstructor) { + expression = this.sanitizeQuery(expression); + var contextId = this._context.id; + var query = this.root._queries.get(contextId, collectionName, expression, options); + if (query) return query; + query = new QueryConstructor(this, collectionName, expression, options); + this.root._queries.add(query); + return query; +}; + +// This method replaces undefined in query objects with null, because +// undefined properties are removed in JSON stringify. This can be dangerous +// in queries, where presenece of a property may indicate that it should be a +// filter and absence means that all values are accepted. We aren't checking +// for cycles, which aren't allowed in JSON, so this could throw a max call +// stack error +Model.prototype.sanitizeQuery = function(expression) { + if (expression && typeof expression === 'object') { + for (var key in expression) { + if (expression.hasOwnProperty(key)) { + var value = expression[key]; + if (value === undefined) { + expression[key] = null; + } else { + this.sanitizeQuery(value); + } + } + } + } + return expression; +}; + +// Called during initialization of the bundle on page load. +Model.prototype._initQueries = function(items: any[][]) { + for (let i = 0; i < items.length; i++) { + const [countsList, collectionName, expression, _results, options, extra] = items[i]; + // const countsList = item[0]; + // const collectionName = item[1]; + // const expression = item[2]; + const results = _results || []; + // const options = item[4]; + // const extra = item[5]; + const [_subscribed, _fetched, contextId] = countsList[0]; + let subscribed = _subscribed || 0; + let fetched = _fetched || 0; + // const contextId = counts[2]; + + const model = (contextId) ? this.context(contextId) : this; + const query = model._getOrCreateQuery(collectionName, expression, options, Query); + + query._setExtra(extra); + + const ids = []; + for (var resultIndex = 0; resultIndex < results.length; resultIndex++) { + const result = results[resultIndex]; + if (typeof result === 'string') { + ids.push(result); + continue; + } + const data = result[0]; + const v = result[1]; + const id = result[2] || data.id; + const type = result[3]; + ids.push(id); + const snapshot = { data: data, v: v, type: type }; + this.getOrCreateDoc(collectionName, id, snapshot); + } + query._addMapIds(ids); + this._set(query.idsSegments, ids); + + while (subscribed--) { + query.subscribe(); + } + query.fetchCount += fetched; + while (fetched--) { + query.context.fetchQuery(query); + } + } +}; + +export class Queries { + map: Record; + collectionMap: CollectionMap; + + constructor() { + // Flattened map of queries by hash. Currently used in contexts + this.map = {}; + // Nested map of queries by collection then hash + this.collectionMap = new CollectionMap(); + } + + add(query: Query) { + this.map[query.hash] = query; + this.collectionMap.set(query.collectionName, query.hash, query); + }; + + remove(query: Query) { + delete this.map[query.hash]; + this.collectionMap.del(query.collectionName, query.hash); + }; + + get(contextId, collectionName, expression, options) { + var hash = queryHash(contextId, collectionName, expression, options); + return this.map[hash]; + }; + + toJSON() { + var out = []; + for (var hash in this.map) { + var query = this.map[hash]; + if (query.subscribeCount || query.fetchCount) { + out.push(query.serialize()); + } + } + return out; + }; +} + +export class Query { + collectionName: string; + context: Context; + created: boolean; + expression: any; + extraSegments: string[]; + fetchCount: number; + hash: string; + idMap: Record; + idsSegments: string[]; + model: Model; + options: any; + segments: Segments; + shareQuery: any | null; + subscribeCount: number; + + _pendingSubscribeCallbacks: any[]; + + constructor(model: ChildModel, collectionName: string, expression: any, options?: any) { + // Note that a new childModel based on the root scope is created. Only the + // context from the passed in model has an effect + this.model = model.root.pass({ $query: this }); + this.context = model._context; + this.collectionName = collectionName; + this.expression = util.deepCopy(expression); + this.options = options; + this.hash = queryHash(this.context.id, collectionName, expression, options); + this.segments = ['$queries', this.hash]; + this.idsSegments = ['$queries', this.hash, 'ids']; + this.extraSegments = ['$queries', this.hash, 'extra']; + + this._pendingSubscribeCallbacks = []; + + // These are used to help cleanup appropriately when calling unsubscribe and + // unfetch. A query won't be fully cleaned up until unfetch and unsubscribe + // are called the same number of times that fetch and subscribe were called. + this.subscribeCount = 0; + this.fetchCount = 0; + + this.created = false; + this.shareQuery = null; + + // idMap is checked in maybeUnload to see if the query is currently holding + // a reference to an id in its results set. This map is duplicative of the + // actual results id list stored in the model, but we are maintaining it, + // because otherwise maybeUnload would be looping through the entire results + // set of each query on the same collection for every doc checked + // + // Map of id -> count of ids + this.idMap = {}; + } + + create() { + this.created = true; + this.model.root._queries.add(this); + }; + + destroy() { + var ids = this.getIds(); + this.created = false; + if (this.shareQuery) { + this.shareQuery.destroy(); + this.shareQuery = null; + } + this.model.root._queries.remove(this); + this.idMap = {}; + this.model._del(this.segments); + this._maybeUnloadDocs(ids); + }; + + fetch(cb?: ErrorCallback) { + cb = this.model.wrapCallback(cb); + this.context.fetchQuery(this); + + this.fetchCount++; + + if (!this.created) this.create(); + + var query = this; + function fetchCb(err: Error, results: any[], extra?: string[]) { + if (err) return cb(err); + query._setExtra(extra); + query._setResults(results); + cb(); + } + this.model.root.connection.createFetchQuery( + this.collectionName, + this.expression, + this.options, + fetchCb + ); + return this; + }; + + fetchPromised = promisify(Query.prototype.fetch); + + subscribe(cb?: ErrorCallback) { + cb = this.model.wrapCallback(cb); + this.context.subscribeQuery(this); + + if (this.subscribeCount++) { + var query = this; + process.nextTick(function() { + var data = query.model._get(query.segments); + if (data) { + cb(); + } else { + query._pendingSubscribeCallbacks.push(cb); + } + }); + return this; + } + + if (!this.created) this.create(); + + var options = (this.options) ? util.copy(this.options) : {}; + options.results = this._getShareResults(); + + // When doing server-side rendering, we actually do a fetch the first time + // that subscribe is called, but keep track of the state as if subscribe + // were called for proper initialization in the client + if (this.model.root.fetchOnly) { + this._shareFetchedSubscribe(options, cb); + } else { + this._shareSubscribe(options, cb); + } + + return this; + }; + + subscribePromised = promisify(Query.prototype.subscribe); + + _subscribeCb(cb: ErrorCallback) { + var query = this; + return function subscribeCb(err: Error, results: ShareDBDoc[], extra?: any) { + if (err) return query._flushSubscribeCallbacks(err, cb); + query._setExtra(extra); + query._setResults(results); + query._flushSubscribeCallbacks(null, cb); + }; + }; + + _shareFetchedSubscribe(options, cb) { + this.model.root.connection.createFetchQuery( + this.collectionName, + this.expression, + options, + this._subscribeCb(cb), + ); + }; + + _shareSubscribe(options, cb) { + var query = this; + // Sanity check, though this shouldn't happen + if (this.shareQuery) { + this.shareQuery.destroy(); + } + this.shareQuery = this.model.root.connection.createSubscribeQuery( + this.collectionName, + this.expression, + options, + this._subscribeCb(cb), + ); + this.shareQuery.on('insert', function(shareDocs, index) { + var ids = resultsIds(shareDocs); + query._addMapIds(ids); + query.model._insert(query.idsSegments, index, ids); + }); + this.shareQuery.on('remove', function(shareDocs, index) { + var ids = resultsIds(shareDocs); + query._removeMapIds(ids); + query.model._remove(query.idsSegments, index, shareDocs.length); + }); + this.shareQuery.on('move', function(shareDocs, from, to) { + query.model._move(query.idsSegments, from, to, shareDocs.length); + }); + this.shareQuery.on('extra', function(extra) { + query.model._setDiffDeep(query.extraSegments, extra); + }); + this.shareQuery.on('error', function(err) { + query.model._emitError(err, query.hash); + }); + }; + + _removeMapIds(ids) { + for (var i = ids.length; i--;) { + var id = ids[i]; + if (this.idMap[id] > 1) { + this.idMap[id]--; + } else { + delete this.idMap[id]; + } + } + // Technically this isn't quite right and we might not wait the full unload + // delay if someone else calls maybeUnload for the same doc id. However, + // it is a lot easier to implement than delaying the removal until later and + // dealing with adds that might happen in the meantime. This will probably + // work to avoid thrashing subscribe/unsubscribe in expected cases + if (this.model.root.unloadDelay) { + var query = this; + setTimeout(function() { + query._maybeUnloadDocs(ids); + }, this.model.root.unloadDelay); + return; + } + this._maybeUnloadDocs(ids); + }; + _addMapIds(ids) { + for (var i = ids.length; i--;) { + var id = ids[i]; + this.idMap[id] = (this.idMap[id] || 0) + 1; + } + }; + _diffMapIds(ids: string[]) { + var addedIds = []; + var removedIds = []; + var newMap = {}; + for (var i = ids.length; i--;) { + var id = ids[i]; + newMap[id] = true; + if (this.idMap[id]) continue; + addedIds.push(id); + } + for (let id in this.idMap) { + if (newMap[id]) continue; + removedIds.push(id); + } + if (addedIds.length) this._addMapIds(addedIds); + if (removedIds.length) this._removeMapIds(removedIds); + }; + _setExtra(extra: string[]) { + if (extra === undefined) return; + this.model._setDiffDeep(this.extraSegments, extra); + }; + _setResults(results: ShareDBDoc[]) { + var ids = resultsIds(results); + this._setResultIds(ids); + }; + _setResultIds(ids: string[]) { + this._diffMapIds(ids); + this.model._setArrayDiff(this.idsSegments, ids); + }; + _maybeUnloadDocs(ids) { + for (var i = 0; i < ids.length; i++) { + var id = ids[i]; + this.model._maybeUnloadDoc(this.collectionName, id); + } + }; + + // Flushes `_pendingSubscribeCallbacks`, calling each callback in the array, + // with an optional error to pass into each. `_pendingSubscribeCallbacks` will + // be empty after this runs. + _flushSubscribeCallbacks(err, cb) { + cb(err); + var pendingCallback; + while ((pendingCallback = this._pendingSubscribeCallbacks.shift())) { + pendingCallback(err); + } + }; + + unfetch(cb?: (err?: Error, count?: number) => void) { + cb = this.model.wrapCallback(cb); + this.context.unfetchQuery(this); + + // No effect if the query is not currently fetched + if (!this.fetchCount) { + cb(); + return this; + } + + var query = this; + if (this.model.root.unloadDelay) { + setTimeout(finishUnfetchQuery, this.model.root.unloadDelay); + } else { + finishUnfetchQuery(); + } + function finishUnfetchQuery() { + var count = --query.fetchCount; + if (count) return cb(null, count); + // Cleanup when no fetches or subscribes remain + if (!query.subscribeCount) query.destroy(); + cb(null, 0); + } + return this; + }; + + unfetchPromised = promisify(Query.prototype.unfetch); + + unsubscribe(cb?: (err?: Error, count?: number) => void) { + cb = this.model.wrapCallback(cb); + this.context.unsubscribeQuery(this); + + // No effect if the query is not currently subscribed + if (!this.subscribeCount) { + cb(); + return this; + } + + var query = this; + if (this.model.root.unloadDelay) { + setTimeout(finishUnsubscribeQuery, this.model.root.unloadDelay); + } else { + finishUnsubscribeQuery(); + } + function finishUnsubscribeQuery() { + var count = --query.subscribeCount; + if (count) return cb(null, count); + + if (query.shareQuery) { + query.shareQuery.destroy(); + query.shareQuery = null; + } + + unsubscribeQueryCallback(); + } + function unsubscribeQueryCallback(err?: Error) { + if (err) return cb(err); + // Cleanup when no fetches or subscribes remain + if (!query.fetchCount) query.destroy(); + cb(null, 0); + } + return this; + }; + + unsubscribePromised = promisify(Query.prototype.unsubscribe); + + _getShareResults() { + var ids = this.model._get(this.idsSegments); + if (!ids) return; + + var collection = this.model.getCollection(this.collectionName); + if (!collection) return; + + var results = []; + for (var i = 0; i < ids.length; i++) { + var id = ids[i]; + var doc = collection.docs[id] as RemoteDoc; + results.push(doc && doc.shareDoc); + } + return results; + }; + + get(): Array { + var results = []; + var data = this.model._get(this.segments); + if (!data) { + console.warn('You must fetch or subscribe to a query before getting its results.'); + return results; + } + var ids = data.ids; + if (!ids) return results; + + var collection = this.model.getCollection(this.collectionName); + for (var i = 0, l = ids.length; i < l; i++) { + var id = ids[i]; + var doc = (collection && collection.docs[id]) as RemoteDoc; + results.push(doc && doc.get()); + } + return results; + }; + + getIds(): string[] { + return this.model._get(this.idsSegments) || []; + }; + + getExtra(): T { + return this.model._get(this.extraSegments) as T; + }; + + ref(from) { + var idsPath = this.idsSegments.join('.'); + return this.model.refList(from, this.collectionName, idsPath); + }; + + refIds(from) { + var idsPath = this.idsSegments.join('.'); + return this.model.ref(from, idsPath); + }; + + refExtra(from, relPath?) { + var extraPath = this.extraSegments.join('.'); + if (relPath) extraPath += '.' + relPath; + return this.model.ref(from, extraPath); + }; + + serialize() { + var ids = this.getIds(); + var collection = this.model.getCollection(this.collectionName); + var results; + if (collection) { + results = []; + for (var i = 0; i < ids.length; i++) { + var id = ids[i]; + var doc = collection.docs[id] as RemoteDoc; + if (doc) { + delete collection.docs[id]; + var data = doc.shareDoc.data; + var result = [data, doc.shareDoc.version]; + if (!data || data.id !== id) { + result[2] = id; + } + if (doc.shareDoc.type !== defaultType) { + result[3] = doc.shareDoc.type && doc.shareDoc.type.name; + } + results.push(result); + } else { + results.push(id); + } + } + } + var subscribed = this.context.subscribedQueries[this.hash] || 0; + var fetched = this.context.fetchedQueries[this.hash] || 0; + var contextId = this.context.id; + var counts = (contextId === 'root') ? + (fetched === 0) ? [subscribed] : [subscribed, fetched] : + [subscribed, fetched, contextId]; + // TODO: change counts to a less obtuse format. We don't want to change + // the serialization format unless it is known that clients are not + // depending on the old format, so this should be done in a major version + var countsList = [counts]; + var serialized = [ + countsList, + this.collectionName, + this.expression, + results, + this.options, + this.getExtra() + ]; + while (serialized[serialized.length - 1] == null) { + serialized.pop(); + } + return serialized; + }; +} + +function queryHash(contextId, collectionName, expression, options) { + var args = [contextId, collectionName, expression, options]; + return JSON.stringify(args).replace(/\./g, '|'); +} + +function resultsIds(results: { id: string }[]): string[] { + var ids = []; + for (var i = 0; i < results.length; i++) { + var shareDoc = results[i]; + ids.push(shareDoc.id); + } + return ids; +} diff --git a/src/Model/RemoteDoc.ts b/src/Model/RemoteDoc.ts new file mode 100644 index 000000000..066ec328c --- /dev/null +++ b/src/Model/RemoteDoc.ts @@ -0,0 +1,654 @@ +/** + * RemoteDoc adapts the ShareJS operation protocol to Racer's mutator + * interface. + * + * 1. It maps Racer's mutator methods to outgoing ShareJS operations. + * 2. It maps incoming ShareJS operations to Racer events. + */ + +import { Doc } from './Doc'; +import { type Collection } from './collections'; +import { type Model } from './Model'; +var util = require('../util'); +var mutationEvents = require('./events').mutationEvents; +var ChangeEvent = mutationEvents.ChangeEvent; +var LoadEvent = mutationEvents.LoadEvent; +var InsertEvent = mutationEvents.InsertEvent; +var RemoveEvent = mutationEvents.RemoveEvent; +var MoveEvent = mutationEvents.MoveEvent; + +export class RemoteDoc extends Doc { + debugMutations: boolean; + shareDoc: any; + + constructor(model: Model, collectionName: string, id: string, snapshot: any, collection: Collection) { + super(model, collectionName, id); + // This is a bit messy, but we have to immediately register this doc on the + // collection that added it, so that when we create the shareDoc and the + // connection emits the 'doc' event, we'll find this doc instead of + // creating a new one + if (collection) collection.docs[id] = this; + this.model = model.pass({ $remote: true }); + this.debugMutations = model.root.debug.remoteMutations; + + // Get or create the Share document. Note that we must have already added + // this doc to the collection to avoid creating a duplicate doc + this.shareDoc = model.root.connection.get(collectionName, id); + this.shareDoc.ingestSnapshot(snapshot); + this._initShareDoc(); + } + + _initShareDoc() { + var doc = this; + var model = this.model; + var collectionName = this.collectionName; + var id = this.id; + var shareDoc = this.shareDoc; + // Override submitOp to disable all writes and perform a dry-run + if (model.root.debug.disableSubmit) { + shareDoc.submitOp = function () { }; + shareDoc.create = function () { }; + shareDoc.del = function () { }; + } + // Subscribe to doc events + shareDoc.on('op', function (op, isLocal) { + // Don't emit on local operations, since they are emitted in the mutator + if (isLocal) return; + doc._updateCollectionData(); + doc._onOp(op); + }); + shareDoc.on('del', function (previous, isLocal) { + // Calling the shareDoc.del method does not emit an operation event, + // so we create the appropriate event here. + if (isLocal) return; + delete doc.collectionData[id]; + var event = new ChangeEvent(undefined, previous, model._pass); + model._emitMutation([collectionName, id], event); + }); + shareDoc.on('create', function (isLocal) { + // Local creates should not emit an event, since they only happen + // implicitly as a result of another mutation, and that operation will + // emit the appropriate event. Remote creates can set the snapshot data + // without emitting an operation event, so an event needs to be emitted + // for them. + if (isLocal) return; + doc._updateCollectionData(); + var value = shareDoc.data; + var event = new ChangeEvent(value, undefined, model._pass); + model._emitMutation([collectionName, id], event); + }); + shareDoc.on('error', function (err) { + model._emitError(err, collectionName + '.' + id); + }); + shareDoc.on('load', function () { + doc._updateCollectionData(); + var value = shareDoc.data; + // If we subscribe to an uncreated document, no need to emit 'load' event + if (value === undefined) return; + var event = new LoadEvent(value, model._pass); + model._emitMutation([collectionName, id], event); + }); + this._updateCollectionData(); + }; + + _updateCollectionData() { + var data = this.shareDoc.data; + if (typeof data === 'object' && !Array.isArray(data) && data !== null) { + data.id = this.id; + } + this.collectionData[this.id] = data; + }; + + create(value, cb) { + if (this.debugMutations) { + console.log('RemoteDoc create', this.path(), value); + } + // We copy the snapshot data at time of create to prevent the id added + // outside of ShareJS from getting stored in the data + var data = util.deepCopy(value); + if (data) delete data.id; + this.shareDoc.create(data, cb); + // The id value will get added to the data that was passed in + this.shareDoc.data = value; + this._updateCollectionData(); + this.model._context.createDoc(this.collectionName, this.id); + return; + }; + + set(segments, value, cb) { + if (this.debugMutations) { + console.log('RemoteDoc set', this.path(segments), value); + } + var previous = this._createImplied(segments); + var lastSegment = segments[segments.length - 1]; + if (previous instanceof ImpliedOp) { + previous.value[lastSegment] = value; + this.shareDoc.submitOp(previous.op, cb); + this._updateCollectionData(); + return; + } + var op = (util.isArrayIndex(lastSegment)) ? + [new ListReplaceOp(segments.slice(0, -1), lastSegment, previous, value)] : + [new ObjectReplaceOp(segments, previous, value)]; + this.shareDoc.submitOp(op, cb); + this._updateCollectionData(); + return previous; + }; + + del(segments, cb) { + if (this.debugMutations) { + console.log('RemoteDoc del', this.path(segments)); + } + if (segments.length === 0) { + var previous = this.get(); + this.shareDoc.del(cb); + delete this.collectionData[this.id]; + return previous; + } + // Don't do anything if the value is already undefined, since + // the del method should not create anything + var previous = this.get(segments); + if (previous === undefined) { + cb(); + return; + } + var op = [new ObjectDeleteOp(segments, previous)]; + this.shareDoc.submitOp(op, cb); + this._updateCollectionData(); + return previous; + }; + + increment(segments, byNumber, cb) { + if (this.debugMutations) { + console.log('RemoteDoc increment', this.path(segments), byNumber); + } + var previous = this._createImplied(segments); + if (previous instanceof ImpliedOp) { + var lastSegment = segments[segments.length - 1]; + previous.value[lastSegment] = byNumber; + this.shareDoc.submitOp(previous.op, cb); + this._updateCollectionData(); + return byNumber; + } + if (previous == null) { + var lastSegment = segments[segments.length - 1]; + const op = (util.isArrayIndex(lastSegment)) ? + [new ListInsertOp(segments.slice(0, -1), lastSegment, byNumber)] : + [new ObjectInsertOp(segments, byNumber)]; + this.shareDoc.submitOp(op, cb); + this._updateCollectionData(); + return byNumber; + } + const op = [new IncrementOp(segments, byNumber)]; + this.shareDoc.submitOp(op, cb); + this._updateCollectionData(); + return previous + byNumber; + }; + + push(segments, value, cb) { + if (this.debugMutations) { + console.log('RemoteDoc push', this.path(segments), value); + } + var shareDoc = this.shareDoc; + function push(arr, fnCb) { + var op = [new ListInsertOp(segments, arr.length, value)]; + shareDoc.submitOp(op, fnCb); + return arr.length; + } + return this._arrayApply(segments, push, cb); + }; + + unshift(segments, value, cb) { + if (this.debugMutations) { + console.log('RemoteDoc unshift', this.path(segments), value); + } + var shareDoc = this.shareDoc; + function unshift(arr, fnCb) { + var op = [new ListInsertOp(segments, 0, value)]; + shareDoc.submitOp(op, fnCb); + return arr.length; + } + return this._arrayApply(segments, unshift, cb); + }; + + insert(segments, index, values, cb) { + if (this.debugMutations) { + console.log('RemoteDoc insert', this.path(segments), index, values); + } + var shareDoc = this.shareDoc; + function insert(arr, fnCb) { + var op = createInsertOp(segments, index, values); + shareDoc.submitOp(op, fnCb); + return arr.length; + } + return this._arrayApply(segments, insert, cb); + }; + + pop(segments, cb) { + if (this.debugMutations) { + console.log('RemoteDoc pop', this.path(segments)); + } + var shareDoc = this.shareDoc; + function pop(arr, fnCb) { + var index = arr.length - 1; + var value = arr[index]; + var op = [new ListRemoveOp(segments, index, value)]; + shareDoc.submitOp(op, fnCb); + return value; + } + return this._arrayApply(segments, pop, cb); + }; + + shift(segments, cb) { + if (this.debugMutations) { + console.log('RemoteDoc shift', this.path(segments)); + } + var shareDoc = this.shareDoc; + function shift(arr, fnCb) { + var value = arr[0]; + var op = [new ListRemoveOp(segments, 0, value)]; + shareDoc.submitOp(op, fnCb); + return value; + } + return this._arrayApply(segments, shift, cb); + }; + + remove(segments, index, howMany, cb) { + if (this.debugMutations) { + console.log('RemoteDoc remove', this.path(segments), index, howMany); + } + var shareDoc = this.shareDoc; + function remove(arr, fnCb) { + var values = arr.slice(index, index + howMany); + var op = []; + for (var i = 0, len = values.length; i < len; i++) { + op.push(new ListRemoveOp(segments, index, values[i])); + } + shareDoc.submitOp(op, fnCb); + return values; + } + return this._arrayApply(segments, remove, cb); + }; + + move(segments, from, to, howMany, cb) { + if (this.debugMutations) { + console.log('RemoteDoc move', this.path(segments), from, to, howMany); + } + var shareDoc = this.shareDoc; + function move(arr, fnCb) { + // Get the return value + var values = arr.slice(from, from + howMany); + + // Build an op that moves each item individually + var op = []; + for (var i = 0; i < howMany; i++) { + op.push(new ListMoveOp(segments, (from < to) ? from : from + howMany - 1, (from < to) ? to + howMany - 1 : to)); + } + shareDoc.submitOp(op, fnCb); + + return values; + } + return this._arrayApply(segments, move, cb); + }; + + stringInsert(segments, index, value, cb) { + if (this.debugMutations) { + console.log('RemoteDoc stringInsert', this.path(segments), index, value); + } + var previous = this._createImplied(segments); + if (previous instanceof ImpliedOp) { + var lastSegment = segments[segments.length - 1]; + previous.value[lastSegment] = value; + this.shareDoc.submitOp(previous.op, cb); + this._updateCollectionData(); + return; + } + if (previous == null) { + var lastSegment = segments[segments.length - 1]; + const op = (util.isArrayIndex(lastSegment)) ? + [new ListInsertOp(segments.slice(0, -1), lastSegment, value)] : + [new ObjectInsertOp(segments, value)]; + this.shareDoc.submitOp(op, cb); + this._updateCollectionData(); + return previous; + } + const op = [new StringInsertOp(segments, index, value)]; + this.shareDoc.submitOp(op, cb); + this._updateCollectionData(); + return previous; + }; + + stringRemove(segments, index, howMany, cb) { + if (this.debugMutations) { + console.log('RemoteDoc stringRemove', this.path(segments), index, howMany); + } + var previous = this._createImplied(segments); + if (previous instanceof ImpliedOp) return; + if (previous == null) return previous; + var removed = previous.slice(index, index + howMany); + var op = [new StringRemoveOp(segments, index, removed)]; + this.shareDoc.submitOp(op, cb); + this._updateCollectionData(); + return previous; + }; + + subtypeSubmit(segments, subtype, subtypeOp, cb) { + if (this.debugMutations) { + console.log('RemoteDoc subtypeSubmit', this.path(segments), subtype, subtypeOp); + } + var previous = this._createImplied(segments); + if (previous instanceof ImpliedOp) { + this.shareDoc.submitOp(previous.op); + previous = undefined; + } + var op = new SubtypeOp(segments, subtype, subtypeOp); + this.shareDoc.submitOp(op, cb); + this._updateCollectionData(); + return previous; + }; + + get(segments?: string[]) { + return util.lookup(segments, this.shareDoc.data); + }; + + _createImplied(segments): any { + if (!this.shareDoc.type) { + throw new Error('Mutation on uncreated remote document'); + } + var parent = this.shareDoc; + var key = 'data'; + var node = parent[key]; + var i = 0; + var nextKey = segments[i++]; + var op, value; + while (nextKey != null) { + if (!node) { + if (op) { + value = value[key] = util.isArrayIndex(nextKey) ? [] : {}; + } else { + value = util.isArrayIndex(nextKey) ? [] : {}; + if (Array.isArray(parent)) { + // @ts-ignore + if (key >= parent.length) { + op = new ListInsertOp(segments.slice(0, i - 2), key, value); + } else { + op = new ListReplaceOp(segments.slice(0, i - 2), key, node, value); + } + } else { + op = new ObjectInsertOp(segments.slice(0, i - 1), value); + } + } + node = value; + } + parent = node; + key = nextKey; + node = parent[key]; + nextKey = segments[i++]; + } + if (op) return new ImpliedOp(op, value); + return node; + }; + + _arrayApply(segments, fn, cb) { + var arr = this._createImplied(segments); + if (arr instanceof ImpliedOp) { + this.shareDoc.submitOp(arr.op); + arr = this.get(segments); + } + if (arr == null) { + var lastSegment = segments[segments.length - 1]; + var op = (util.isArrayIndex(lastSegment)) ? + [new ListInsertOp(segments.slice(0, -1), lastSegment, [])] : + [new ObjectInsertOp(segments, [])]; + this.shareDoc.submitOp(op); + arr = this.get(segments); + } + + if (!Array.isArray(arr)) { + var message = this._errorMessage(fn.name + ' on non-array', segments, arr); + var err = new TypeError(message); + return cb(err); + } + var out = fn(arr, cb); + this._updateCollectionData(); + return out; + }; + + _onOp(op) { + var item; + if (op.length === 1) { + // ShareDB docs shatter json0 ops into single components during apply + item = op[0]; + } else if (op.length === 0) { + // Ignore no-ops + return; + } else { + try { + op = JSON.stringify(op); + } catch (err) { } + throw new Error('Received op with multiple components from ShareDB ' + op); + } + var segments = [this.collectionName, this.id].concat(item.p); + var model = this.model; + + // ObjectReplaceOp, ObjectInsertOp, or ObjectDeleteOp + if (defined(item.oi) || defined(item.od)) { + var value = item.oi; + var previous = item.od; + var event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + + // ListReplaceOp + } else if (defined(item.li) && defined(item.ld)) { + var value = item.li; + var previous = item.ld; + var event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + + // ListInsertOp + } else if (defined(item.li)) { + var index = segments[segments.length - 1]; + var values = [item.li]; + var event = new InsertEvent(index, values, model._pass); + model._emitMutation(segments.slice(0, -1), event); + + // ListRemoveOp + } else if (defined(item.ld)) { + var index = segments[segments.length - 1]; + var removed = [item.ld]; + var event = new RemoveEvent(index, removed, model._pass); + model._emitMutation(segments.slice(0, -1), event); + + // ListMoveOp + } else if (defined(item.lm)) { + var from = segments[segments.length - 1]; + var to = item.lm; + var howMany = 1; + var event = new MoveEvent(from, to, howMany, model._pass); + model._emitMutation(segments.slice(0, -1), event); + + // StringInsertOp + } else if (defined(item.si)) { + var index = segments[segments.length - 1]; + var text = item.si; + segments = segments.slice(0, -1); + var value = model._get(segments); + var previous = value.slice(0, index) + value.slice(index + text.length); + var pass = model.pass({ $stringInsert: { index: index, text: text } })._pass; + var event = new ChangeEvent(value, previous, pass); + model._emitMutation(segments, event); + + // StringRemoveOp + } else if (defined(item.sd)) { + var index = segments[segments.length - 1]; + var text = item.sd; + const howMany = text.length; + segments = segments.slice(0, -1); + var value = model._get(segments); + var previous = value.slice(0, index) + text + value.slice(index); + var pass = model.pass({ $stringRemove: { index: index, howMany: howMany } })._pass; + var event = new ChangeEvent(value, previous, pass); + model._emitMutation(segments, event); + + // IncrementOp + } else if (defined(item.na)) { + var value = this.get(item.p); + const previous = value - item.na; + var event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + + // SubtypeOp + } else if (defined(item.t)) { + var value = this.get(item.p); + // Since this is generic to all subtypes, we don't know how to get a copy + // of the previous value efficiently. We could make a copy eagerly, but + // given that embedded types are likely to be used for custom editors, + // we'll assume they primarily use the returned op and are unlikely to + // need the previous snapshot data + var previous = undefined; + var type = item.t; + var op = item.o; + var pass = model.pass({ $subtype: { type: type, op: op } })._pass; + var event = new ChangeEvent(value, previous, pass); + model._emitMutation(segments, event); + } + }; +} + +function createInsertOp(segments, index, values) { + if (!Array.isArray(values)) { + return [new ListInsertOp(segments, index, values)]; + } + var op = []; + for (var i = 0, len = values.length; i < len; i++) { + op.push(new ListInsertOp(segments, index++, values[i])); + } + return op; +} + +class ImpliedOp { + op: any; + value: any; + + constructor(op, value) { + this.op = op; + this.value = value; + } +} + +class ObjectReplaceOp { + p: any; + od: any; + oi: any; + + constructor(segments, before, after) { + this.p = util.castSegments(segments); + this.od = before; + this.oi = (after === undefined) ? null : after; + } +} + +class ObjectInsertOp { + p: any; + oi: any; + + constructor(segments, value) { + this.p = util.castSegments(segments); + this.oi = (value === undefined) ? null : value; + } +} + +class ObjectDeleteOp { + p: any; + od: any; + + constructor(segments, value) { + this.p = util.castSegments(segments); + this.od = (value === undefined) ? null : value; + } +} + +class ListReplaceOp { + p: any; + ld: any; + li: any; + + constructor(segments, index, before, after) { + this.p = util.castSegments(segments.concat(index)); + this.ld = before; + this.li = (after === undefined) ? null : after; + } +} + +class ListInsertOp { + p: any; + li: any; + + constructor(segments, index, value) { + this.p = util.castSegments(segments.concat(index)); + this.li = (value === undefined) ? null : value; + } +} + +class ListRemoveOp { + p: any; + ld: any; + + constructor(segments, index, value) { + this.p = util.castSegments(segments.concat(index)); + this.ld = (value === undefined) ? null : value; + } +} + +class ListMoveOp { + p: any; + lm: any; + + constructor(segments, from, to) { + this.p = util.castSegments(segments.concat(from)); + this.lm = to; + } +} + +class StringInsertOp { + p: any; + si: any; + constructor(segments, index, value) { + this.p = util.castSegments(segments.concat(index)); + this.si = value; + } +} + +class StringRemoveOp { + p: any; + sd: string; + constructor(segments, index: number, value: string) { + this.p = util.castSegments(segments.concat(index)); + this.sd = value; + } +} + +class IncrementOp { + p: any; + na: any; + constructor(segments, byNumber) { + this.p = util.castSegments(segments); + this.na = byNumber; + } +} + +class SubtypeOp { + p: any; + t: any; + o: any; + + constructor(segments, subtype, subtypeOp) { + this.p = util.castSegments(segments); + this.t = subtype; + this.o = subtypeOp; + } +} + +function defined(value) { + return value !== undefined; +} diff --git a/src/Model/bundle.ts b/src/Model/bundle.ts new file mode 100644 index 000000000..0b9c860fa --- /dev/null +++ b/src/Model/bundle.ts @@ -0,0 +1,97 @@ +import { Model } from './Model'; +var defaultType = require('sharedb/lib/client').types.defaultType; +var promisify = require('../util').promisify; + +declare module './Model' { + interface Model { + bundleTimeout: number; + bundle(cb: (err?: Error, bundle?: any) => void): void; + bundlePromised(): Promise; + } +} + +const BUNDLE_TIMEOUT = 10 * 1000; + +Model.INITS.push(function(model, options) { + model.root.bundleTimeout = options.bundleTimeout || BUNDLE_TIMEOUT; +}); + +Model.prototype.bundle = function(cb) { + var root = this.root; + var timeout = setTimeout(function() { + var message = 'Model bundle took longer than ' + root.bundleTimeout + 'ms'; + var err = new Error(message); + cb(err); + // Keep the callback from being called more than once + cb = function() {}; + }, root.bundleTimeout); + + root.whenNothingPending(function finishBundle() { + clearTimeout(timeout); + var bundle = { + collections: undefined, + queries: root._queries.toJSON(), + contexts: root._contexts.toJSON(), + refs: root._refs.toJSON(), + refLists: root._refLists.toJSON(), + fns: root._fns.toJSON(), + filters: root._filters.toJSON(), + nodeEnv: process.env.NODE_ENV, + }; + stripComputed(root); + bundle.collections = serializeCollections(root); + root.emit('bundle', bundle); + root._commit = errorOnCommit; + cb(null, bundle); + }); +}; +Model.prototype.bundlePromised = promisify(Model.prototype.bundle); + +function stripComputed(root) { + var silentModel = root.silent(); + root._refLists.fromMap.forEach(function(refList) { + silentModel._del(refList.fromSegments); + }); + root._refs.fromMap.forEach(function(ref) { + silentModel._delNoDereference(ref.fromSegments); + }); + root._fns.fromMap.forEach(function(fn) { + silentModel._del(fn.fromSegments); + }); + silentModel.removeAllFilters(); + silentModel.destroy('$queries'); +} + +function serializeCollections(root) { + var out = {}; + for (var collectionName in root.collections) { + var collection = root.collections[collectionName]; + out[collectionName] = {}; + for (var id in collection.docs) { + var doc = collection.docs[id]; + var shareDoc = doc.shareDoc; + var snapshot; + if (shareDoc) { + if (shareDoc.type == null && shareDoc.version == null) { + snapshot = undefined; + } else { + snapshot = { + v: shareDoc.version, + data: shareDoc.data + }; + if (shareDoc.type !== defaultType) { + snapshot.type = doc.shareDoc.type && doc.shareDoc.type.name; + } + } + } else { + snapshot = doc.data; + } + out[collectionName][id] = snapshot; + } + } + return out; +} + +function errorOnCommit() { + this.emit('error', new Error('Model mutation performed after bundling')); +} diff --git a/src/Model/collections.ts b/src/Model/collections.ts new file mode 100644 index 000000000..d8da68ed0 --- /dev/null +++ b/src/Model/collections.ts @@ -0,0 +1,304 @@ +import { Doc } from './Doc'; +import { Model, RootModel } from './Model'; +import { JSONObject } from 'sharedb/lib/sharedb'; +import type { Path, ReadonlyDeep, ShallowCopiedValue, Segments } from '../types'; + +var LocalDoc = require('./LocalDoc'); +var util = require('../util'); + +export class ModelCollections { + docs: Record; +} + +/** Root model data */ +export class ModelData { + [collectionName: string]: CollectionData; +} + +class DocMap { + [id: string]: Doc; +} + +/** Dictionary of document id to document data */ +export class CollectionData { + [id: string]: T; +} + +declare module './Model' { + interface RootModel { + collections: ModelCollections; + data: ModelData; + } + interface Model { + destroy(subpath?: Path): void; + + /** + * Gets the value located at this model's path. + * + * If no value exists at the path, this returns `undefined`. + * + * _Note:_ The value is returned by reference, and object values should not + * be directly modified - use the Model mutator methods instead. The + * TypeScript compiler will enforce no direct modifications, but there are + * no runtime guards, which means JavaScript source code could still + * improperly make direct modifications. + */ + get(): ReadonlyDeep | undefined; + /** + * Gets the value located at a relative subpath. + * + * If no value exists at the path, this returns `undefined`. + * + * _Note:_ The value is returned by reference, and object values should not + * be directly modified - use the Model mutator methods instead. The + * TypeScript compiler will enforce no direct modifications, but there are + * no runtime guards, which means JavaScript source code could still + * improperly make direct modifications. + * + * @param subpath + */ + get(subpath?: Path): ReadonlyDeep | undefined; + + getCollection(collectionName: string): Collection; + + /** + * Gets a shallow copy of the value located at this model's path or a relative + * subpath. + * + * If no value exists at the path, this returns `undefined`. + * + * @param subpath + */ + getCopy(subpath: Path): ShallowCopiedValue | undefined; + getCopy(): ShallowCopiedValue | undefined; + + /** + * Gets a deep copy of the value located at this model's path or a relative + * subpath. + * + * If no value exists at the path, this returns `undefined`. + * + * @param subpath + */ + getDeepCopy(subpath: Path): S | undefined; + getDeepCopy(): T | undefined; + + getDoc(collecitonName: string, id: string): any | undefined; + getOrCreateCollection(name: string): Collection; + getOrCreateDoc(collectionName: string, id: string, data: any); + + /** + * Gets value at the path if not nullish, otherwise returns provided default value + * + * @param subpath + * @param defaultValue value to return if no value at subpath + */ + getOrDefault(subpath: Path, defaultValue: S): ReadonlyDeep; + + /** + * Gets the value located at this model's path or a relative subpath. + * + * If no value exists at the path, or the value is nullish (null or undefined), this will throw an error. + * @param subpath + */ + getOrThrow(subpath: Path): ReadonlyDeep; + + _get(segments: Segments): any; + _getCopy(segments: Segments): any; + _getDeepCopy(segments: Segments): any; + + /** + * Gets array of values of collection at this model's path or relative subpath + * + * If no values exist at subpath, an empty array is returned + * @param subpath + */ + getValues(subpath?: Path): ReadonlyDeep[]; + } +} + +Model.INITS.push(function(model) { + model.root.collections = new ModelCollections(); + model.root.data = new ModelData(); +}); + +Model.prototype.getCollection = function(collectionName) { + return this.root.collections[collectionName]; +}; + +Model.prototype.getDoc = function(collectionName, id) { + var collection = this.root.collections[collectionName]; + return collection && collection.docs[id]; +}; + +Model.prototype.get = function(subpath?: Path) { + var segments = this._splitPath(subpath); + return this._get(segments) as ReadonlyDeep; +}; + +Model.prototype._get = function(segments) { + return util.lookup(segments, this.root.data); +}; + +Model.prototype.getCopy = function(subpath?: Path) { + var segments = this._splitPath(subpath); + return this._getCopy(segments) as ReadonlyDeep; +}; + +Model.prototype._getCopy = function(segments) { + var value = this._get(segments); + return util.copy(value); +}; + +Model.prototype.getDeepCopy = function(subpath?: Path) { + var segments = this._splitPath(subpath); + return this._getDeepCopy(segments) as S; +}; + +Model.prototype._getDeepCopy = function(segments) { + var value = this._get(segments); + return util.deepCopy(value); +}; + +Model.prototype.getValues = function(subpath?: Path) { + const value = this.get(subpath); + if (value == null) { + return []; + } + if (typeof value !== 'object') { + throw new Error(`Found non-object type for getValues('${this.path(subpath)}')`); + } + return Object.values(value) as ReadonlyDeep[]; +} + +Model.prototype.getOrCreateCollection = function(name) { + var collection = this.root.collections[name]; + if (collection) return collection; + var Doc = this._getDocConstructor(name); + collection = new Collection(this.root, name, Doc); + this.root.collections[name] = collection; + return collection; +}; + +Model.prototype._getDocConstructor = function(name: string) { + // Only create local documents. This is overriden in ./connection.js, so that + // the RemoteDoc behavior can be selectively included + return LocalDoc; +}; + +/** + * Returns an existing document with id in a collection. If the document does + * not exist, then creates the document with id in a collection and returns the + * new document. + * @param {String} collectionName + * @param {String} id + * @param {Object} [data] data to create if doc with id does not exist in collection + */ +Model.prototype.getOrCreateDoc = function(collectionName, id, data) { + var collection = this.getOrCreateCollection(collectionName); + return collection.getOrCreateDoc(id, data); +}; + +Model.prototype.getOrDefault = function(subpath: Path, defaultValue: S) { + return this.get(subpath) ?? defaultValue as ReadonlyDeep; +}; + +Model.prototype.getOrThrow = function(subpath?: Path) { + const value = this.get(subpath); + if (value == null) { + const fullpath = this.path(subpath); + throw new Error(`No value at path ${fullpath}`) + } + return value; +}; + +/** + * @param {String} subpath + */ +Model.prototype.destroy = function(subpath) { + var segments = this._splitPath(subpath); + // Silently remove all types of listeners within subpath + var silentModel = this.silent(); + silentModel._removeAllListeners(null, segments); + silentModel._removeAllRefs(segments); + silentModel._stopAll(segments); + silentModel._removeAllFilters(segments); + // Remove listeners created within the model's eventContext and remove the + // reference to the eventContext + silentModel.removeContextListeners(); + // Silently remove all model data within subpath + if (segments.length === 0) { + this.root.collections = new ModelCollections(); + // Delete each property of data instead of creating a new object so that + // it is possible to continue using a reference to the original data object + var data = this.root.data; + for (var key in data) { + delete data[key]; + } + } else if (segments.length === 1) { + var collection = this.getCollection(segments[0]); + collection && collection.destroy(); + } else { + silentModel._del(segments); + } +}; + +export class Collection { + model: RootModel; + name: string; + size: number; + docs: DocMap; + data: CollectionData; + Doc: typeof Doc; + + constructor(model: RootModel, name: string, docClass: typeof Doc) { + this.model = model; + this.name = name; + this.Doc = docClass; + this.size = 0; + this.docs = new DocMap(); + this.data = model.data[name] = new CollectionData(); + } + + /** + * Adds a document with `id` and `data` to `this` Collection. + * @param {String} id + * @param {Object} data + * @return {LocalDoc|RemoteDoc} doc + */ + add(id, data) { + var doc = new this.Doc(this.model, this.name, id, data, this); + this.docs[id] = doc; + return doc; + }; + + destroy() { + delete this.model.collections[this.name]; + delete this.model.data[this.name]; + }; + + getOrCreateDoc(id, data) { + var doc = this.docs[id]; + if (doc) return doc; + this.size++; + return this.add(id, data); + }; + + /** + * Removes the document with `id` from `this` Collection. If there are no more + * documents in the Collection after the given document is removed, then this + * destroys the Collection. + * + * @param {String} id + */ + remove(id: string) { + if (!this.docs[id]) return; + this.size--; + if (this.size > 0) { + delete this.docs[id]; + delete this.data[id]; + } else { + this.destroy(); + } + } +}; diff --git a/src/Model/connection.server.ts b/src/Model/connection.server.ts new file mode 100644 index 000000000..f85afbb2a --- /dev/null +++ b/src/Model/connection.server.ts @@ -0,0 +1,24 @@ +import { Model } from './Model'; + +declare module './Model' { + interface Model { + createConnection(backend: any, req?: any): void; + connect(): void; + connection: any; + } +} + +Model.prototype.createConnection = function(backend, req) { + this.root.backend = backend; + this.root.req = req; + this.root.connection = backend.connect(null, req); + this.root.socket = this.root.connection.socket; + // Pretend like we are always connected on the server for rendering purposes + this._set(['$connection', 'state'], 'connected'); + this._finishCreateConnection(); +}; + +Model.prototype.connect = function() { + this.root.backend.connect(this.root.connection, this.root.req); + this.root.socket = this.root.connection.socket; +}; diff --git a/src/Model/connection.ts b/src/Model/connection.ts new file mode 100644 index 000000000..7b342a1b4 --- /dev/null +++ b/src/Model/connection.ts @@ -0,0 +1,150 @@ +import { Connection } from 'sharedb/lib/client'; +import { Model } from './Model'; +import { type Doc} from './Doc'; +import { LocalDoc} from './LocalDoc'; +import {RemoteDoc} from './RemoteDoc'; +import type Agent = require('sharedb/lib/agent'); +var promisify = require('../util').promisify; + +export { type Connection }; + +declare module './Model' { + interface DocConstructor { + new (any: unknown[]): DocConstructor; + } + interface Model { + /** Returns a child model where ShareDB operations are always composed. */ + allowCompose(): ChildModel; + close(cb?: (err?: Error) => void): void; + closePromised: () => Promise; + disconnect(): void; + + /** + * Returns a reference to the ShareDB agent if it is connected directly on the + * server. Will return null if the ShareDB connection has been disconnected or + * if we are not in the same process and we do not have a reference to the + * server-side agent object + */ + getAgent(): Agent; + + hasPending(): boolean; + hasWritePending(): boolean; + /** Returns a child model where ShareDB operations are never composed. */ + preventCompose(): ChildModel; + reconnect(): void; + + /** + * Calls the callback once all pending operations, fetches, and subscribes + * have settled. + */ + whenNothingPending(cb: () => void): void; + whenNothingPendingPromised(): Promise; + + _finishCreateConnection(): void; + _getDocConstructor(name: string): any; + _isLocal(name: string): boolean; + } +} + +Model.INITS.push(function(model) { + model.root._preventCompose = false; +}); + +Model.prototype.preventCompose = function() { + var model = this._child(); + model._preventCompose = true; + return model; +}; + +Model.prototype.allowCompose = function() { + var model = this._child(); + model._preventCompose = false; + return model; +}; + +Model.prototype.createConnection = function(bundle) { + // Model::_createSocket should be defined by the socket plugin + this.root.socket = this._createSocket(bundle); + + // The Share connection will bind to the socket by defining the onopen, + // onmessage, etc. methods + var model = this; + this.root.connection = new Connection(this.root.socket); + this.root.connection.on('state', function(state, reason) { + model._setDiff(['$connection', 'state'], state); + model._setDiff(['$connection', 'reason'], reason); + }); + this._set(['$connection', 'state'], 'connected'); + + this._finishCreateConnection(); +}; + +Model.prototype._finishCreateConnection = function() { + var model = this; + this.root.connection.on('error', function(err) { + model._emitError(err); + }); + // Share docs can be created by queries, so we need to register them + // with Racer as soon as they are created to capture their events + this.root.connection.on('doc', function(shareDoc) { + model.getOrCreateDoc(shareDoc.collection, shareDoc.id); + }); +}; + +Model.prototype.connect = function() { + this.root.socket.open(); +}; + +Model.prototype.disconnect = function() { + this.root.socket.close(); +}; + +Model.prototype.reconnect = function() { + this.disconnect(); + this.connect(); +}; + +// Clean delayed disconnect +Model.prototype.close = function(cb) { + cb = this.wrapCallback(cb); + var model = this; + this.whenNothingPending(function() { + model.root.socket.close(); + cb(); + }); +}; +Model.prototype.closePromised = promisify(Model.prototype.close); + +// Returns a reference to the ShareDB agent if it is connected directly on the +// server. Will return null if the ShareDB connection has been disconnected or +// if we are not in the same process and we do not have a reference to the +// server-side agent object +Model.prototype.getAgent = function() { + return this.root.connection.agent; +}; + +Model.prototype._isLocal = function(name) { + // Whether the collection is local or remote is determined by its name. + // Collections starting with an underscore ('_') are for user-defined local + // collections, those starting with a dollar sign ('$'') are for + // framework-defined local collections, and all others are remote. + var firstCharcter = name.charAt(0); + return firstCharcter === '_' || firstCharcter === '$'; +}; + +Model.prototype._getDocConstructor = function(name: string) { + return (this._isLocal(name)) ? LocalDoc : RemoteDoc; +}; + +Model.prototype.hasPending = function() { + return this.root.connection.hasPending(); +}; + +Model.prototype.hasWritePending = function() { + return this.root.connection.hasWritePending(); +}; + +Model.prototype.whenNothingPending = function(cb) { + return this.root.connection.whenNothingPending(cb); +}; +Model.prototype.whenNothingPendingPromised = promisify(Model.prototype.whenNothingPending); diff --git a/src/Model/contexts.ts b/src/Model/contexts.ts new file mode 100644 index 000000000..95fdaf46d --- /dev/null +++ b/src/Model/contexts.ts @@ -0,0 +1,219 @@ +/** + * Contexts are useful for keeping track of the origin of subscribes. + */ + +import { Model } from './Model'; +import { CollectionCounter } from './CollectionCounter'; + +declare module './Model' { + interface Model { + /** + * Creates a new child model with a specific named data-loading context. The + * child model has the same scoped path as this model. + * + * Contexts are used to track counts of fetches and subscribes, so that all + * data relating to a context can be unloaded all at once, without having to + * manually track loaded data. + * + * Contexts are in a global namespace for each root model, so calling + * `model.context(contextId)` from two different places will return child + * models that both refer to the same context. + * + * @param contextId - context id + * + * @see https://derbyjs.github.io/derby/models/contexts + */ + context(contextId: string): ChildModel; + /** + * Get the named context or create a new named context if it doesnt exist. + * + * @param contextId context id + * + * @see https://derbyjs.github.io/derby/models/contexts + */ + getOrCreateContext(contextId: string): Context; + /** + * Set the named context to use for this model. + * + * @param contextId context id + * + * @see https://derbyjs.github.io/derby/models/contexts + */ + setContext(contextId: string): void; + + /** + * Unloads data for this model's context, or for a specific named context. + * + * @param contextId - optional context to unload; defaults to this model's context + * + * @see https://derbyjs.github.io/derby/models/contexts + */ + unload(contextId?: string): void; + + /** + * Unloads data for all model contexts. + * + * @see https://derbyjs.github.io/derby/models/contexts + */ + unloadAll(): void; + + _contexts: Contexts; + } +} + +Model.INITS.push(function(model) { + model.root._contexts = new Contexts(); + model.root.setContext('root'); +}); + +Model.prototype.context = function(contextId) { + var model = this._child(); + model.setContext(contextId); + return model; +}; + +Model.prototype.setContext = function(contextId) { + this._context = this.getOrCreateContext(contextId); +}; + +Model.prototype.getOrCreateContext = function(contextId) { + var context = this.root._contexts[contextId] || + (this.root._contexts[contextId] = new Context(this, contextId)); + return context; +}; + +Model.prototype.unload = function(contextId) { + var context = (contextId) ? this.root._contexts[contextId] : this._context; + context && context.unload(); +}; + +Model.prototype.unloadAll = function() { + var contexts = this.root._contexts; + for (var key in contexts) { + const currentContext = contexts[key]; + if (contexts.hasOwnProperty(key)) { + currentContext.unload(); + } + } +}; + +export class Contexts { + toJSON() { + var out: Record = {}; + var contexts = this; + for (var key in contexts) { + const currentContext = contexts[key]; + if (currentContext instanceof Context) { + out[key] = currentContext.toJSON(); + } + } + return out; + }; +} + +class FetchedQueries { } +class SubscribedQueries { } + +export class Context { + model: Model; + id: string; + fetchedDocs: CollectionCounter; + subscribedDocs: CollectionCounter; + createdDocs: CollectionCounter; + fetchedQueries: FetchedQueries; + subscribedQueries: SubscribedQueries; + + constructor(model: Model, id: string) { + this.model = model; + this.id = id; + this.fetchedDocs = new CollectionCounter(); + this.subscribedDocs = new CollectionCounter(); + this.createdDocs = new CollectionCounter(); + this.fetchedQueries = new FetchedQueries(); + this.subscribedQueries = new SubscribedQueries(); + } + + toJSON() { + var fetchedDocs = this.fetchedDocs.toJSON(); + var subscribedDocs = this.subscribedDocs.toJSON(); + var createdDocs = this.createdDocs.toJSON(); + if (!fetchedDocs && !subscribedDocs && !createdDocs) return; + return { + fetchedDocs: fetchedDocs, + subscribedDocs: subscribedDocs, + createdDocs: createdDocs + }; + }; + + fetchDoc(collectionName, id) { + this.fetchedDocs.increment(collectionName, id); + }; + subscribeDoc(collectionName, id) { + this.subscribedDocs.increment(collectionName, id); + }; + unfetchDoc(collectionName, id) { + this.fetchedDocs.decrement(collectionName, id); + }; + unsubscribeDoc(collectionName, id) { + this.subscribedDocs.decrement(collectionName, id); + }; + createDoc(collectionName, id) { + this.createdDocs.increment(collectionName, id); + }; + fetchQuery(query) { + mapIncrement(this.fetchedQueries, query.hash); + }; + subscribeQuery(query) { + mapIncrement(this.subscribedQueries, query.hash); + }; + unfetchQuery(query) { + mapDecrement(this.fetchedQueries, query.hash); + }; + unsubscribeQuery(query) { + mapDecrement(this.subscribedQueries, query.hash); + }; + + unload() { + var model = this.model; + for (var hash in this.fetchedQueries) { + var query = model.root._queries.map[hash]; + if (!query) continue; + var count = this.fetchedQueries[hash]; + while (count--) query.unfetch(); + } + for (var hash in this.subscribedQueries) { + var query = model.root._queries.map[hash]; + if (!query) continue; + var count = this.subscribedQueries[hash]; + while (count--) query.unsubscribe(); + } + for (var collectionName in this.fetchedDocs.collections) { + var collection = this.fetchedDocs.collections[collectionName]; + for (var id in collection.counts) { + var count = collection.counts[id]; + while (count--) model.unfetchDoc(collectionName, id); + } + } + for (var collectionName in this.subscribedDocs.collections) { + var collection = this.subscribedDocs.collections[collectionName]; + for (var id in collection.counts) { + var count = collection.counts[id]; + while (count--) model.unsubscribeDoc(collectionName, id); + } + } + for (var collectionName in this.createdDocs.collections) { + var collection = this.createdDocs.collections[collectionName]; + for (var id in collection.counts) { + model._maybeUnloadDoc(collectionName, id); + } + } + this.createdDocs.reset(); + }; +} +function mapIncrement(map, key) { + map[key] = (map[key] || 0) + 1; +} +function mapDecrement(map, key) { + map[key] && map[key]--; + if (!map[key]) delete map[key]; +} diff --git a/lib/Model/defaultFns.js b/src/Model/defaultFns.ts similarity index 59% rename from lib/Model/defaultFns.js rename to src/Model/defaultFns.ts index 3c06aeb4f..f749fdd0e 100644 --- a/lib/Model/defaultFns.js +++ b/src/Model/defaultFns.ts @@ -1,10 +1,4 @@ -var defaultFns = module.exports = new DefaultFns(); -defaultFns.reverse = new FnPair(getReverse, setReverse); -defaultFns.asc = asc; -defaultFns.desc = desc; - -function DefaultFns() {} function FnPair(get, set) { this.get = get; this.set = set; @@ -13,16 +7,20 @@ function FnPair(get, set) { function getReverse(array) { return array && array.slice().reverse(); } + function setReverse(values) { return {0: getReverse(values)}; } -function asc(a, b) { +export const reverse = new FnPair(getReverse, setReverse); + +export function asc(a, b) { if (a < b) return -1; if (a > b) return 1; return 0; } -function desc(a, b) { + +export function desc(a, b) { if (a > b) return -1; if (a < b) return 1; return 0; diff --git a/src/Model/events.ts b/src/Model/events.ts new file mode 100644 index 000000000..ea488b77e --- /dev/null +++ b/src/Model/events.ts @@ -0,0 +1,786 @@ +// @ts-check + +import { EventEmitter } from 'events'; +import { EventListenerTree } from './EventListenerTree'; +import { Model } from './Model'; +import { mergeInto } from '../util'; +import type { Path, PathLike, Segments } from '../types'; + +export type ModelEvent = + | ChangeEvent + | InsertEvent + | RemoveEvent + | MoveEvent + | LoadEvent + | UnloadEvent; + +export type ModelOnEventMap = { + [eventName in ModelEvent['type']]: Extract; +}; +export type ModelOnImmediateEventMap = { + [eventName in ModelEvent['_immediateType']]: Extract; +}; + +/** + * With `useEventObjects: true` captures are emmitted as + * ['foo.bar.1'] + * */ +type EventObjectCaptures = string[]; + +declare module './Model' { + interface RootModel { + // duplicated w on signatures below due to how TS handles overrides + on( + eventType: T, + pathPattern: PathLike, + options: { useEventObjects: true }, + listener: (event: ModelOnEventMap[T], captures: EventObjectCaptures) => void + ): () => void; + on( + eventType: 'all', + pathPattern: PathLike, + options: { useEventObjects: true }, + listener: (event: ModelEvent, captures: EventObjectCaptures) => void + ): () => void; + on( + eventType: T, + options: { useEventObjects: true }, + listener: (event: ModelOnEventMap[T], captures: EventObjectCaptures) => void + ): () => void; + on( + eventType: 'all', + options: { useEventObjects: true }, + listener: (event: ModelEvent, captures: EventObjectCaptures) => void + ): () => void; + on( + eventType: T, + listener: (pathSegments: string[], event: ModelOnImmediateEventMap[T]) => void + ): () => void; + on( + eventType: 'all', + listener: (pathSegments: string[], event: ModelOnEventMap[keyof ModelOnEventMap]) => void + ): () => void; + on( + eventType: 'error', + listener: (error: Error) => void + ): () => void; + } + + interface Model { + addListener(event: string, listener: any, arg2?: any, arg3?: any): any; + eventContext(id: string): ChildModel; + + /** + * Listen to Racer events matching a certain path or path pattern. + * + * `pathPattern` is a path pattern that will filter emitted events, calling + * the handler function only when a mutator matches the pattern. + * + * Path patterns support a single segment wildcard `'*'` anywhere in a path, + * and a multi-segment wildcard `'**'` at the end of the path. The + * multi-segment wildcard alone `'**'` matches all paths. + * + * Examples of path patterns: + * * `'notes.abc-123.author'` - Trigger on a direct modification to a + * specific note's `author`. Will not trigger if a sub-property of the + * author is modified or if the entire note is replaced. + * * `notes.*.author` - Trigger on a direct modification to any note's + * `author`. Will not trigger if a sub-property of the author is modified + * or if an entire note is replaced. + * * `notes.*.author.**` - Trigger on a modification to any note's `author` + * or any sub-property of `author`. Will not trigger if an entire note is + * replaced. + * + * @param eventType + * @param pathPattern + * @param options + * @param listener + * + * @see https://derbyjs.github.io/derby/models/events + */ + on( + eventType: T, + pathPattern: PathLike, + options: { useEventObjects: true }, + listener: (event: ModelOnEventMap[T], captures: EventObjectCaptures) => void + ): () => void; + on( + eventType: 'all', + pathPattern: PathLike, + options: { useEventObjects: true }, + listener: (event: ModelEvent, captures: EventObjectCaptures) => void + ): () => void; + on( + eventType: T, + options: { useEventObjects: true }, + listener: (event: ModelOnEventMap[T], captures: EventObjectCaptures) => void + ): () => void; + on( + eventType: 'all', + options: { useEventObjects: true }, + listener: (event: ModelEvent, captures: EventObjectCaptures) => void + ): () => void; + + /** + * Listen to Racer events matching a certain path or path pattern, removing + * the listener after it gets triggered once. + * + * @param eventType + * @param pathPattern + * @param options + * @param listener + * + * @see https://derbyjs.github.io/derby/components/events + */ + once( + eventType: T, + pathPattern: string, + options: { useEventObjects: true }, + listener: (event: ModelOnEventMap[T], captures: Array) => void + ): Function; + once( + eventType: T, + options: { useEventObjects: true }, + listener: (event: ModelOnEventMap[T], captures: Array) => void + ): Function; + + /** + * Passes data to event listeners + * + * @param object - An object whose properties will each be set on the passed argument + * @returns back a model scoped to the same path + */ + pass(object: object, invert?: boolean): Model; + + removeAllListeners(type: string, subpath: Path): void; + removeContextListeners(): void; + + removeListener(eventType: keyof ModelOnEventMap | keyof ModelOnImmediateEventMap | 'all', listener: Function): void; + + setMaxListeners(limit: number): void; + silent(value?: boolean): Model; + wrapCallback(cb?: ErrorCallback): ErrorCallback; + + __on: typeof EventEmitter.prototype.on; + __once: typeof EventEmitter.prototype.once; + __removeAllListeners: typeof EventEmitter.prototype.removeAllListeners; + __removeListener: typeof EventEmitter.prototype.removeListener; + _addMutationListener(type: string, arg1: any, arg2: any, arg3: any): MutationListener; + _callMutationListeners(type: string, segments: Segments, event: any): void; + _defaultCallback(err?: Error): void; + _emitError(err: Error, context?: any): void; + _emitMutation(segments: Segments, event: any): void; + _emittingMutation: boolean; + _eventContextListeners: Record; + _mutationEventQueue: null; + _mutationListeners: Record; + _removeAllListeners(type: string, segments: Segments): void; + _removeMutationListener(listener: MutationListener): void; + } +} + +Model.INITS.push(function(model) { + var root = model.root; + EventEmitter.call(root); + + // Set max listeners to unlimited + model.setMaxListeners(0); + + // Used in async methods to emit an error event if a callback is not supplied. + // This will throw if there is no handler for model.on('error') + root._defaultCallback = defaultCallback; + function defaultCallback(err) { + if (err) model._emitError(err); + } + + var mutationListeners = { + all: new EventListenerTree() + }; + + for (var name in mutationEvents) { + var eventPrototype = mutationEvents[name].prototype; + mutationListeners[eventPrototype.type] = new EventListenerTree(); + mutationListeners[eventPrototype._immediateType] = new EventListenerTree(); + } + root._mutationListeners = mutationListeners; + root._emittingMutation = false; + root._mutationEventQueue = null; + root._pass = new Passed(); + root._silent = false; + root._eventContextListeners = {}; + root._eventContext = null; +}); + +mergeInto(Model.prototype, EventEmitter.prototype); + +Model.prototype.wrapCallback = function(cb) { + if (!cb) return this.root._defaultCallback; + var model = this; + return function wrappedCallback() { + try { + return cb.apply(this, arguments); + } catch (err) { + model._emitError(err); + } + }; +}; + +Model.prototype._emitError = function(err, context) { + var message = (err.message) ? err.message : + (typeof err === 'string') ? err : + 'Unknown model error'; + if (context) { + message += ' ' + context; + } + // @ts-ignore + if (err.data) { + try { + // @ts-ignore + message += ' ' + JSON.stringify(err.data); + } catch (stringifyErr) { } + } + if (err instanceof Error) { + err.message = message; + } else { + err = new Error(message); + } + this.emit('error', err); +}; + +Model.prototype._emitMutation = function(segments, event) { + if (this._silent) return; + var root = this.root; + this._callMutationListeners(event._immediateType, segments, event); + if (root._emittingMutation) { + if (root._mutationEventQueue) { + root._mutationEventQueue.push(segments, event); + } else { + root._mutationEventQueue = [segments, event]; + } + return; + } + root._emittingMutation = true; + this._callMutationListeners(event.type, segments, event); + this._callMutationListeners('all', segments, event); + var limit = 1000; + while (root._mutationEventQueue) { + if (--limit < 0) { + throw new Error( + 'Maximum model mutation event cycles exceeded. Most likely, an event ' + + 'listener is performing a mutation that emits an event to the same ' + + 'listener, directly or indirectly. This creates an infinite cycle. Queue details: \n' + + JSON.stringify(root._mutationEventQueue, null, 2) + ); + } + var queue = root._mutationEventQueue; + root._mutationEventQueue = null; + for (var i = 0; i < queue.length;) { + segments = queue[i++]; + event = queue[i++]; + this._callMutationListeners(event.type, segments, event); + this._callMutationListeners('all', segments, event); + } + } + root._emittingMutation = false; +}; + +Model.prototype._callMutationListeners = function(type, segments, event) { + var tree = this.root._mutationListeners[type]; + var listeners = tree.getWildcardListeners(segments); + for (var i = 0, len = listeners.length; i < len; i++) { + var fn = listeners[i].fn; + fn(segments, event); + } +}; + +// EventEmitter.prototype.on, EventEmitter.prototype.addListener, and +// EventEmitter.prototype.once return `this`. The Model equivalents return +// the listener instead, since it is made internally for method subscriptions +// and may need to be passed to removeListener. +Model.prototype.__on = EventEmitter.prototype.on; +// @ts-expect-error ignore method overload issues +Model.prototype.addListener = Model.prototype.on = function(type, arg1, arg2, arg3) { + var listener = this._addMutationListener(type, arg1, arg2, arg3); + if (listener) { + return listener; + } + // Normal event + this.__on(type, arg1); + return arg1; +}; + +Model.prototype.__once = EventEmitter.prototype.once; +// @ts-expect-error ignore method overload issues +Model.prototype.once = function(type, arg1, arg2, arg3) { + var listener = this._addMutationListener(type, arg1, arg2, arg3); + if (listener) { + onceWrapListener(this, listener); + return listener; + } + // Normal event + this.__once(type, arg1); + return arg1; +}; + +function onceWrapListener(model, listener) { + var fn = listener.fn; + listener.fn = function onceWrapper(segments, event) { + model._removeMutationListener(listener); + fn(segments, event); + }; +} + +Model.prototype.__removeListener = EventEmitter.prototype.removeListener; +Model.prototype.removeListener = function(type, listener) { + if (this.root._mutationListeners[type]) { + this._removeMutationListener(listener); + return; + } + // Normal event + this.__removeListener(type, listener); +}; + +Model.prototype.__removeAllListeners = EventEmitter.prototype.removeAllListeners; +Model.prototype.removeAllListeners = function(type, subpath) { + var segments = this._splitPath(subpath); + this._removeAllListeners(type, segments); +}; +Model.prototype._removeAllListeners = function(type, segments) { + var mutationListeners = this.root._mutationListeners; + if (type == null) { + for (var key in mutationListeners) { + var tree = mutationListeners[key]; + tree.removeAllListeners(segments); + } + return; + } + var tree = mutationListeners[type]; + if (tree) { + tree.removeAllListeners(segments); + return; + } + // Normal event + this.__removeAllListeners(type); +}; + +export class Passed { } + +Model.prototype.pass = (Object.assign) ? + function(object, invert) { + var model = this._child(); + model._pass = (invert) ? + Object.assign(new Passed(), object, this._pass) : + Object.assign(new Passed(), this._pass, object); + return model; + } : + function(object, invert) { + var model = this._child(); + var pass = new Passed(); + if (invert) { + mergeInto(pass, object); + mergeInto(pass, this._pass); + } else { + mergeInto(pass, this._pass); + mergeInto(pass, object); + } + model._pass = pass; + return model; + }; + +/** + * The returned Model will or won't trigger event handlers when the model emits + * events, depending on `value` + * @param {Boolean|Null} value defaults to true + * @return {Model} + */ +Model.prototype.silent = function(value) { + var model = this._child(); + model._silent = (value == null) ? true : !!value; + return model; +}; + +Model.prototype.eventContext = function(id) { + var model = this._child(); + model._eventContext = id; + return model; +}; + +Model.prototype.removeContextListeners = function() { + var id = this._eventContext; + if (id == null) return; + var map = this.root._eventContextListeners; + var listeners = map[id]; + if (!listeners) return; + delete map[id]; + for (var i = listeners.length; i--;) { + var listener = listeners[i]; + listener.node.removeOwnListener(listener); + } +}; + +Model.prototype._removeMutationListener = function(listener) { + listener.node.removeOwnListener(listener); + var id = this._eventContext; + if (id == null) return; + var map = this.root._eventContextListeners; + var listeners = map[id]; + if (!listeners) return; + // Always iterate though all listeners rather than breaking early. A listener + // may be in the list more than once, since model._addContextListener() + // doesn't prevent it at time of adding + for (var i = listeners.length; i--;) { + if (listeners[i] === listener) { + listeners.splice(i, 1); + } + } +}; + +Model.prototype._addMutationListener = function(type, arg1, arg2, arg3) { + var tree = this.root._mutationListeners[type]; + if (!tree) return; + // Create double-linked listener and tree node for later removal + var listener = getMutationListener(this, type, arg1, arg2, arg3); + var node = tree.addListener(listener.patternSegments, listener); + listener.node = node; + // Maintain an index of listeners by eventContext id + var id = this._eventContext; + if (id == null) return listener; + var map = this.root._eventContextListeners; + var listeners = map[id]; + if (listeners) { + // Unlike a typical event listener, don't check to see if a listener is + // already tracked on add. Instead, check to see if the listener might occur + // more than once when removing. Generally, listeners are expected to be + // added many at a time during rendering and removed all at once with + // model.removeContextListeners(). Individual removes are expected to be + // infrequent and individual adds are expected to be frequent + listeners.push(listener); + } else { + map[id] = [listener]; + } + return listener; +}; + +/** + * @typedef {Object} ModelOnOptions + * @property {boolean} [useEventObjects] - If true, the listener is called with + * `cb(event: ___Event, captures: string[])`, instead of the legacy var-args + * style `cb(captures..., [eventType], eventArgs..., passed)`. + */ + +/** + * @param model + * @param type + */ +function getMutationListener(model, type, arg1, arg2, arg3) { + var pattern, options, cb; + if (typeof arg3 === 'function') { + // on(type, subpath, options, cb) + pattern = model.path(arg1); + options = arg2; + cb = arg3; + } else if (typeof arg2 === 'function') { + // on(type, subpath, cb) + // model.on('change', 'example.subpath.**', callback) + // model.at('example').on('change', 'subpath', callback) + // on(type, options, cb) + // model.at('example').on('change', {useEventObjects: true}, callback) + pattern = model.path(arg1); + if (pattern == null) { + options = arg1; + pattern = model.path(); + } + cb = arg2; + } else if (typeof arg1 === 'function') { + // on(type, cb) + // Normal (non-mutator) event: + // model.on('normalEvent', callback) + // Path from scoped model: + // model.at('example').on('change', callback) + // Raw event emission: + // model.on('change', callback) + pattern = model.path(); + cb = arg1; + } else { + throw new Error('No expected callback function'); + } + if (!pattern) { + // Listen to raw event emission when no path is provided + return new MutationListener(['**'], model._eventContext, cb); + } + pattern = normalizePattern(pattern); + return (options && options.useEventObjects) ? + createMutationListener(pattern, model._eventContext, cb) : + createMutationListenerLegacy(type, pattern, model._eventContext, cb); +} + +function createCaptures(captureIndicies, remainingIndex, segments) { + var captures = []; + if (captureIndicies) { + for (var i = 0; i < captureIndicies.length; i++) { + var index = captureIndicies[i]; + captures.push(segments[index]); + } + } + if (remainingIndex != null) { + var remainder = segments.slice(remainingIndex).join('.'); + captures.push(remainder); + } + return captures; +} + +class MutationListener { + patternSegments: string[]; + eventContext: any; + fn: any; + node: any | null; + + constructor(patternSegments, eventContext, fn) { + this.patternSegments = patternSegments; + this.eventContext = eventContext; + this.fn = fn; + this.node = null; + } +} + +function createMutationListener(pattern, eventContext, cb) { + var patternSegments = pattern.split('.'); + var fn; + if (patternSegments.length === 1 && patternSegments[0] === '**') { + fn = function(segments, event) { + var captures = [segments.join('.')]; + cb(event, captures); + }; + } else { + var captureIndicies, remainingIndex; + for (var i = 0; i < patternSegments.length; i++) { + var segment = patternSegments[i]; + if (segment === '*') { + if (captureIndicies) { + captureIndicies.push(i); + } else { + captureIndicies = [i]; + } + } else if (segment === '**') { + if (i !== patternSegments.length - 1) { + throw new Error('Path pattern may contain `**` at end only'); + } + remainingIndex = i; + } + } + if (captureIndicies || remainingIndex != null) { + fn = function(segments, event) { + var captures = createCaptures(captureIndicies, remainingIndex, segments); + cb(event, captures); + }; + } else { + fn = function(segments, event) { + cb(event, []); + }; + } + } + return new MutationListener(patternSegments, eventContext, fn); +} + +function createMutationListenerLegacy(type, pattern, eventContext, cb) { + var mutationListenerAdapter = (type === 'all') ? + function(event, captures) { + var args = captures.concat(event.type, event._getArgs()); + cb.apply(null, args); + } : + function(event, captures) { + var args = captures.concat(event._getArgs()); + cb.apply(null, args); + }; + return createMutationListener(pattern, eventContext, mutationListenerAdapter); +} + +export class ChangeEvent { + declare type: 'change'; + declare _immediateType: 'changeImmediate'; + value: any; + previous: any; + passed: any; + + constructor(value, previous, passed) { + this.value = value; + this.previous = previous; + this.passed = passed; + } + + clone() { + return new ChangeEvent(this.value, this.previous, this.passed); + }; + + _getArgs() { + return [this.value, this.previous, this.passed]; + }; +} +ChangeEvent.prototype.type = 'change'; +ChangeEvent.prototype._immediateType = 'changeImmediate'; + +export class LoadEvent { + declare type: 'load'; + declare _immediateType: 'loadImmediate'; + value: any; + document: any; + passed: any; + + constructor(value, passed) { + /** + * The documented public name of the loaded item is `document` + * However we use `value` internally so both are provided + * Using `document` is preferred + */ + this.value = value; + this.document = value; + this.passed = passed; + } + + clone() { + return new LoadEvent(this.value, this.passed); + }; + + _getArgs() { + return [this.value, this.passed]; + }; +} +LoadEvent.prototype.type = 'load'; +LoadEvent.prototype._immediateType = 'loadImmediate'; + +export class UnloadEvent { + declare type: 'unload'; + declare _immediateType: 'unloadImmediate'; + previous: any; + previousDocument: any; + passed: any; + + constructor(previous, passed) { + /** + * The documented public name of the unloaded item is `previousDocument` + * However we use `previous` internally so both are provided + * Using `previousDocument` is preferred + */ + this.previous = previous; + this.previousDocument = previous; + this.passed = passed; + } + + clone() { + return new UnloadEvent(this.previous, this.passed); + }; + + _getArgs() { + return [this.previous, this.passed]; + }; +} +UnloadEvent.prototype.type = 'unload'; +UnloadEvent.prototype._immediateType = 'unloadImmediate'; + +export class InsertEvent { + declare type: 'insert'; + declare _immediateType: 'insertImmediate'; + index: number; + values: any[]; + passed: any; + + constructor(index, values, passed) { + this.index = index; + this.values = values; + this.passed = passed; + } + + clone() { + return new InsertEvent(this.index, this.values, this.passed); + }; + + _getArgs() { + return [this.index, this.values, this.passed]; + }; +} +InsertEvent.prototype.type = 'insert'; +InsertEvent.prototype._immediateType = 'insertImmediate'; + +export class RemoveEvent { + declare type: 'remove'; + declare _immediateType: 'removeImmediate'; + index: number; + passed: any; + removed: any[]; + /** @deprecated Use `removed` instead */ + values: any[]; + + constructor(index, values, passed) { + this.index = index; + /** + * The documented public name of the removed item is `removed` + * However we use `values` internally so both are provided + * Using `removed` is preferred + */ + this.values = values; + this.removed = values; + this.passed = passed; + } + + clone() { + return new RemoveEvent(this.index, this.values, this.passed); + }; + + _getArgs() { + return [this.index, this.values, this.passed]; + }; +} +RemoveEvent.prototype.type = 'remove'; +RemoveEvent.prototype._immediateType = 'removeImmediate'; + +export class MoveEvent { + declare type: 'move'; + declare _immediateType: 'moveImmediate'; + from: number; + howMany: number; + passed: any; + to: number; + + constructor(from, to, howMany, passed) { + this.from = from; + this.to = to; + this.howMany = howMany; + this.passed = passed; + } + + clone() { + return new MoveEvent(this.from, this.to, this.howMany, this.passed); + }; + + _getArgs() { + return [this.from, this.to, this.howMany, this.passed]; + }; +} +MoveEvent.prototype.type = 'move'; +MoveEvent.prototype._immediateType = 'moveImmediate'; + +// DEPRECATED: Normalize pattern ending in '**' to '.**', since these are +// treated equivalently. The '.**' form is preferred, and it should be enforced +// in a future version for clarity +function normalizePattern(pattern) { + var end = pattern.length - 1; + return ( + pattern.charAt(end) === '*' && + pattern.charAt(end - 1) === '*' && + pattern.charAt(end - 2) !== '.' && + pattern.charAt(end - 2) + ) ? pattern.slice(0, end - 1) + '.**' : pattern; +}; + + +// These events are re-emitted as 'all' events, and they are queued up and +// emitted in sequence, so that events generated by other events are not +// seen in a different order by later listeners +export const mutationEvents = { + ChangeEvent, + LoadEvent, + UnloadEvent, + InsertEvent, + RemoveEvent, + MoveEvent, +}; \ No newline at end of file diff --git a/src/Model/filter.ts b/src/Model/filter.ts new file mode 100644 index 000000000..928dc1346 --- /dev/null +++ b/src/Model/filter.ts @@ -0,0 +1,380 @@ +var util = require('../util'); +import { Model } from './Model'; +import * as defaultFns from './defaultFns'; +import type { Path, PathLike, Segments } from '../types'; + +interface PaginationOptions { + skip?: number; + limit?: number; +} + +type FilterFn = + | ((item: S, key: string, object: { [key: string]: S }) => boolean) + | string + | null; +type SortFn = (a: S, B: S) => number; + +declare module './Model' { + interface Model { + /** + * Creates a live-updating list from items in an object, which results in + * automatically updating as the input items change. + * + * @param inputPath - Path pointing to an object or array. The path's value is + * retrieved via model.get(), and each item checked against filter function + * @param additionalInputPaths - Other parameters can be set in the model, and + * the filter function will be re-evaluated when these parameters change as well. + * @param options + * skip - The number of first results to skip + * limit - The maximum number of results. A limit of zero is equivalent to no limit. + * @param fn - A function or the name of a function defined via model.fn(). The function + * should have the arguments function(item, key, object, additionalInputs...) + * + * @see https://derbyjs.github.io/derby/models/filters-sorts + */ + filter( + inputPath: PathLike, + additionalInputPaths: PathLike[], + options: PaginationOptions, + fn: FilterFn + ): Filter; + filter( + inputPath: PathLike, + additionalInputPaths: PathLike[], + fn: FilterFn + ): Filter; + filter( + inputPath: PathLike, + options: PaginationOptions, + fn: FilterFn + ): Filter; + filter( + inputPath: PathLike, + fn: FilterFn + ): Filter; + + removeAllFilters: (subpath: Path) => void; + + /** + * Creates a live-updating list from items in an object, which results in + * automatically updating as the input items change. The results are sorted by ascending order (default) or by a provided 'fn' parameter. + * + * @param inputPath - Path pointing to an object or array. The path's value is + * retrieved via model.get(), and each item checked against filter function + * @param additionalInputPaths - Other parameters can be set in the model, and + * the filter function will be re-evaluated when these parameters change as well. + * @param options + * skip - The number of first results to skip + * limit - The maximum number of results. A limit of zero is equivalent to no limit. + * @param fn - A function or the name of a function defined via model.fn(). + * + * @see https://derbyjs.github.io/derby/models/filters-sorts + */ + sort( + inputPath: PathLike, + additionalInputPaths: PathLike[], + options: PaginationOptions, + fn: SortFn + ): Filter; + sort( + inputPath: PathLike, + additionalInputPaths: PathLike[], + fn: SortFn + ): Filter; + sort(inputPath: PathLike, options: PaginationOptions, fn: SortFn): Filter; + sort(inputPath: PathLike, fn: SortFn): Filter; + + _filters: Filters; + _removeAllFilters: (segments: Segments) => void; + } +} + +Model.INITS.push(function(model) { + model.root._filters = new Filters(model); + model.on('all', filterListener); + function filterListener(segments, event) { + var passed = event.passed; + var map = model.root._filters.fromMap; + for (var path in map) { + var filter = map[path]; + if (passed.$filter === filter) continue; + if ( + util.mayImpact(filter.segments, segments) || + (filter.inputsSegments && util.mayImpactAny(filter.inputsSegments, segments)) + ) { + filter.update(passed); + } + } + } +}); + +function parseFilterArguments(model, args) { + var fn = args.pop(); + var options, inputPaths; + var path = model.path(args.shift()); + var last = args[args.length - 1]; + if (!model.isPath(last) && !Array.isArray(last)) { + options = args.pop(); + } + if (args.length === 1 && Array.isArray(args[0])) { + // inputPaths provided as one array: + // model.filter(inputPath, [inputPath1, inputPath2], fn) + inputPaths = args[0]; + } else { + // inputPaths provided as var-args: + // model.filter(inputPath, inputPath1, inputPath2, fn) + inputPaths = args; + } + var i = inputPaths.length; + while (i--) { + inputPaths[i] = model.path(inputPaths[i]); + } + return { + path: path, + inputPaths: (inputPaths.length) ? inputPaths : null, + options: options, + fn: fn + }; +} + +Model.prototype.filter = function() { + var args = Array.prototype.slice.call(arguments); + var parsed = parseFilterArguments(this, args); + return this.root._filters.add( + parsed.path, + parsed.fn, + null, + parsed.inputPaths, + parsed.options + ); +}; + +Model.prototype.sort = function() { + var args = Array.prototype.slice.call(arguments); + var parsed = parseFilterArguments(this, args); + return this.root._filters.add( + parsed.path, + null, + parsed.fn || 'asc', + parsed.inputPaths, + parsed.options + ); +}; + +Model.prototype.removeAllFilters = function(subpath) { + var segments = this._splitPath(subpath); + this._removeAllFilters(segments); +}; +Model.prototype._removeAllFilters = function(segments) { + var filters = this.root._filters.fromMap; + for (var from in filters) { + if (util.contains(segments, filters[from].fromSegments)) { + filters[from].destroy(); + } + } +}; + +class FromMap {} + +class Filters{ + model: Model; + fromMap: FromMap; + constructor(model) { + this.model = model; + this.fromMap = new FromMap(); + } + + add(path: Path, filterFn, sortFn, inputPaths, options) { + return new Filter(this, path, filterFn, sortFn, inputPaths, options); + }; + + toJSON() { + var out = []; + for (var from in this.fromMap) { + var filter = this.fromMap[from]; + // Don't try to bundle if functions were passed directly instead of by name + if (!filter.bundle) continue; + var args = [from, filter.path, filter.filterName, filter.sortName, filter.inputPaths]; + if (filter.options) args.push(filter.options); + out.push(args); + } + return out; + }; +} + +export class Filter { + bundle: boolean; + filterFn: any; + filterName: string; + filters: any; + from: string; + fromSegments: string[] + idsSegments: Segments; + inputPaths: any; + inputsSegments: Segments[]; + limit: number; + model: Model; + options: any; + path: string; + segments: Segments; + skip: number; + sortFn: any; + sortName: string; + + constructor(filters, path, filterFn, sortFn, inputPaths, options) { + this.filters = filters; + this.model = filters.model.pass({$filter: this}); + this.path = path; + this.segments = path.split('.'); + this.filterName = null; + this.sortName = null; + this.bundle = true; + this.filterFn = null; + this.sortFn = null; + this.inputPaths = inputPaths; + this.inputsSegments = null; + if (inputPaths) { + this.inputsSegments = []; + for (var i = 0; i < this.inputPaths.length; i++) { + var segments = this.inputPaths[i].split('.'); + this.inputsSegments.push(segments); + } + } + this.options = options; + this.skip = options && options.skip; + this.limit = options && options.limit; + if (filterFn) this.filter(filterFn); + if (sortFn) this.sort(sortFn); + this.idsSegments = null; + this.from = null; + this.fromSegments = null; + } + + filter(fn) { + if (typeof fn === 'function') { + this.filterFn = fn; + this.bundle = false; + return this; + } else if (typeof fn === 'string') { + this.filterName = fn; + this.filterFn = this.model.root._namedFns[fn] || defaultFns[fn]; + if (!this.filterFn) { + throw new TypeError('Filter function not found: ' + fn); + } + } + return this; + }; + + sort(fn) { + if (!fn) fn = 'asc'; + if (typeof fn === 'function') { + this.sortFn = fn; + this.bundle = false; + return this; + } else if (typeof fn === 'string') { + this.sortName = fn; + this.sortFn = this.model.root._namedFns[fn] || defaultFns[fn]; + if (!this.sortFn) { + throw new TypeError('Sort function not found: ' + fn); + } + } + return this; + }; + + _slice(results) { + if (this.skip == null && this.limit == null) return results; + var begin = this.skip || 0; + // A limit of zero is equivalent to setting no limit + var end; + if (this.limit) end = begin + this.limit; + return results.slice(begin, end); + }; + + getInputs() { + if (!this.inputsSegments) return; + var inputs = []; + for (var i = 0, len = this.inputsSegments.length; i < len; i++) { + var input = this.model._get(this.inputsSegments[i]); + inputs.push(input); + } + return inputs; + }; + + callFilter(items, key, inputs) { + var item = items[key]; + return (inputs) ? + this.filterFn.apply(this.model, [item, key, items].concat(inputs)) : + this.filterFn.call(this.model, item, key, items); + }; + + ids(): string[] { + var items = this.model._get(this.segments); + var ids = []; + if (!items) return ids; + if (Array.isArray(items)) { + throw new Error('model.filter is not currently supported on arrays'); + } + if (this.filterFn) { + var inputs = this.getInputs(); + for (var key in items) { + if (items.hasOwnProperty(key) && this.callFilter(items, key, inputs)) { + ids.push(key); + } + } + } else { + ids = Object.keys(items); + } + var sortFn = this.sortFn; + if (sortFn) { + ids.sort(function(a, b) { + return sortFn(items[a], items[b]); + }); + } + return this._slice(ids); + }; + + get(): S[] { + var items = this.model._get(this.segments); + var results = []; + if (Array.isArray(items)) { + throw new Error('model.filter is not currently supported on arrays'); + } + if (this.filterFn) { + var inputs = this.getInputs(); + for (var key in items) { + if (items.hasOwnProperty(key) && this.callFilter(items, key, inputs)) { + results.push(items[key]); + } + } + } else { + for (var key in items) { + if (items.hasOwnProperty(key)) { + results.push(items[key]); + } + } + } + if (this.sortFn) results.sort(this.sortFn); + return this._slice(results); + }; + + update(pass?: any) { + var ids = this.ids(); + this.model.pass(pass, true)._setArrayDiff(this.idsSegments, ids); + }; + + ref(from) { + from = this.model.path(from); + this.from = from; + this.fromSegments = from.split('.'); + this.filters.fromMap[from] = this; + this.idsSegments = ['$filters', from.replace(/\./g, '|')]; + this.update(); + return this.model.refList(from, this.path, this.idsSegments.join('.')); + }; + + destroy() { + delete this.filters.fromMap[this.from]; + this.model._removeRef(this.idsSegments); + this.model._del(this.idsSegments); + }; +} diff --git a/src/Model/fn.ts b/src/Model/fn.ts new file mode 100644 index 000000000..c66ba73d0 --- /dev/null +++ b/src/Model/fn.ts @@ -0,0 +1,418 @@ +import { Model } from './Model'; +import { EventListenerTree } from './EventListenerTree'; +import { EventMapTree } from './EventMapTree'; +import * as defaultFns from './defaultFns'; +import type { Path, PathLike, ReadonlyDeep, Segments } from '../types'; +var util = require('../util'); + +class NamedFns { } + +type StartFnParam = unknown; + +// From type-fest: https://github.com/sindresorhus/type-fest +type ArrayIndices = + Exclude['length'], Element['length']>; + +type TwoWayReactiveFnSetReturnType = + Partial | + Partial<{ [K in Extract, number>]: Ins[K] }> | + null; + +type ModelFn = + ((...inputs: Ins) => Out) | + { + get(...inputs: Ins): Out; + set(output: Out, ...inputs: Ins): TwoWayReactiveFnSetReturnType; + }; + +interface ModelStartOptions { + /** + * Whether to deep-copy the input/output of the reactive function. + * + * - `output` (default) + * - `input` + * - `both` + * - `none` + */ + copy?: 'output' | 'input' | 'both' | 'none'; + /** + * Comparison mode for the output of the reactive function, when determining + * whether and how to update the output path based on the function's return + * value. + * + * - `'diffDeep'` (default) - Do a recursive deep-equal comparison on old + * and new output values, attempting to issue fine-grained ops on subpaths + * where possible. + * - `'diff` - Do an identity comparison (`===`) on the output value, and do + * a simple set if old and new outputs are different. + * - `'arrayDeep'` - Compare old and new arrays item-by-item using a + * deep-equal comparison for each item, issuing top-level array insert, + * remove,, and move ops as needed. Unlike `'diffDeep'`, this will _not_ + * issue ops inside array items. + * - `'array'` - Compare old and new arrays item-by-item using identity + * comparison (`===`) for each item, issuing top-level array insert, + * remove,, and move ops as needed. + */ + mode?: 'diffDeep' | 'diff' | 'arrayDeep' | 'array'; + /** + * If true, then upon input changes, defer evaluation of the function to the + * next tick, instead of immediately evaluating the function upon each input + * change. + * + * _Warning:_ Avoid using `async: true` if there's any controller code that + * does a `model.get()` on the output path or on any paths downstream of the + * output, since changes to an input path won't immediately result in the + * output being updated. + */ + async?: boolean; +} + +declare module './Model' { + interface Model { + /** + * Call the function with the values at the input paths, returning the value + * on completion. Unlike `start`, this only occurs once and does not create + * a listener for updating based on changes. + * + * The function should be a pure function - it should always return the same + * result given the same inputs, and it should be side-effect free. + * + * @param inputPaths + * @param options + * @param fn + * + * @see https://derbyjs.github.io/derby/models/reactive-functions + */ + evaluate( + inputPaths: PathLike[], + options: ModelStartOptions, + fn: (...inputs: Ins) => Out + ): Out; + evaluate( + inputPaths: PathLike[], + fn: (...inputs: Ins) => Out + ): Out; + + /** + * Defines a named reactive function. + * + * It's not recommended to use this in most cases. Instead, to share reactive functions, + * have the components import a shared function to pass to `model.start`. + * + * @deprecated The use of named functions is deprecated. Instead, to share a reactive function, + * you should export it and then require/import it into each file that needs to use it. + * + * @param name name of the function to define + * @param fn either a reactive function that accepts inputs and returns output, or + * a `{ get: Function; set: Function }` object defining a two-way reactive function + */ + fn( + name: string, + fn: ModelFn + ): void; + + /** + * Call the function with the values at the input paths, writing the return + * value to the output path. In addition, whenever any of the input values + * change, re-invoke the function and set the new return value to the output + * path. + * + * The function should be a pure function - it should always return the same + * result given the same inputs, and it should be side-effect free. + * + * @param outputPath + * @param inputPaths + * @param options + * @param fn - a reactive function that accepts inputs and returns output; + * a `{ get: Function; set: Function }` object defining a two-way reactive function; + * or the name of a function defined via model.fn() + * + * @see https://derbyjs.github.io/derby/models/reactive-functions + */ + start( + outputPath: PathLike, + inputPaths: PathLike[], + options: ModelStartOptions, + fn: ModelFn | string + ): Out; + start( + outputPath: PathLike, + inputPaths: PathLike[], + fn: ModelFn | string + ): Out; + + stop(subpath: Path): void; + stopAll(subpath: Path): void; + + _fns: Fns; + _namedFns: NamedFns; + _stop(segments: Segments): void; + _stopAll(segments: Segments): void; + } +} + +Model.INITS.push(function (model) { + var root = model.root; + root._namedFns = new NamedFns(); + root._fns = new Fns(root); + addFnListener(root); +}); + +function addFnListener(model) { + var inputListeners = model._fns.inputListeners; + var fromMap = model._fns.fromMap; + model.on('all', function fnListener(segments, event) { + var passed = event.passed; + // Mutation affecting input path + var fns = inputListeners.getAffectedListeners(segments); + for (var i = 0; i < fns.length; i++) { + var fn = fns[i]; + if (fn !== passed.$fn) fn.onInput(passed); + } + // Mutation affecting output path + var fns = fromMap.getAffectedListeners(segments); + for (var i = 0; i < fns.length; i++) { + var fn = fns[i]; + if (fn !== passed.$fn) fn.onOutput(passed); + } + }); +} + +Model.prototype.fn = function (name, fns) { + this.root._namedFns[name] = fns; +}; + +function parseStartArguments(model, args, hasPath) { + var last = args.pop(); + var fns, name; + if (typeof last === 'string') { + name = last; + } else { + fns = last; + } + // For `Model#start`, the first parameter is the output path. + var path; + if (hasPath) { + path = model.path(args.shift()); + } + // The second-to-last original argument could be an options object. + // If it's not an array and not path-like, then it's an options object. + last = args[args.length - 1]; + var options; + if (!Array.isArray(last) && !model.isPath(last)) { + options = args.pop(); + } + + // `args` is just the input paths at this point. + var inputs; + if (args.length === 1 && Array.isArray(args[0])) { + // Inputs provided as one array: + // model.start(outPath, [inPath1, inPath2], fn); + inputs = args[0]; + } else { + // Inputs provided as var-args: + // model.start(outPath, inPath1, inPath2, fn); + inputs = args; + } + + // Normalize each input into a string path. + var i = inputs.length; + while (i--) { + inputs[i] = model.path(inputs[i]); + } + return { + name: name, + path: path, + inputPaths: inputs, + fns: fns, + options: options + }; +} + +Model.prototype.evaluate = function () { + var args = Array.prototype.slice.call(arguments); + var parsed = parseStartArguments(this, args, false); + return this.root._fns.get(parsed.name, parsed.inputPaths, parsed.fns, parsed.options); +}; + +Model.prototype.start = function () { + var args = Array.prototype.slice.call(arguments); + var parsed = parseStartArguments(this, args, true); + return this.root._fns.start(parsed.name, parsed.path, parsed.inputPaths, parsed.fns, parsed.options); +}; + +Model.prototype.stop = function (subpath) { + var segments = this._splitPath(subpath); + this._stop(segments); +}; +Model.prototype._stop = function (segments) { + this.root._fns.stop(segments); +}; + +Model.prototype.stopAll = function (subpath) { + var segments = this._splitPath(subpath); + this._stopAll(segments); +}; +Model.prototype._stopAll = function (segments) { + this.root._fns.stopAll(segments); +}; + +class Fns { + model: Model; + nameMap: NamedFns; + fromMap: EventMapTree; + inputListeners: EventListenerTree; + + constructor(model: Model) { + this.model = model; + this.nameMap = model._namedFns; + this.fromMap = new EventMapTree(); + this.inputListeners = new EventListenerTree(); + } + + _removeInputListeners(fn) { + for (var i = 0; i < fn.inputsSegments.length; i++) { + var inputSegements = fn.inputsSegments[i]; + this.inputListeners.removeListener(inputSegements, fn); + } + }; + + get(name: string, inputPaths: any, fns: any, options: any) { + fns || (fns = this.nameMap[name] || defaultFns[name]); + var fn = new Fn(this.model, name, null, inputPaths, fns, options); + return fn.get(); + }; + + start(name: string, path: string, inputPaths: any, fns: any, options: any) { + fns || (fns = this.nameMap[name] || defaultFns[name]); + var fn = new Fn(this.model, name, path, inputPaths, fns, options); + var previous = this.fromMap.setListener(fn.fromSegments, fn); + if (previous) { + this._removeInputListeners(previous); + } + for (var i = 0; i < fn.inputsSegments.length; i++) { + var inputSegements = fn.inputsSegments[i]; + this.inputListeners.addListener(inputSegements, fn); + } + return fn._onInput(); + }; + + stop(segments: Segments) { + var previous = this.fromMap.deleteListener(segments); + if (previous) { + this._removeInputListeners(previous); + } + }; + + stopAll(segments: Segments) { + var node = this.fromMap.deleteAllListeners(segments); + if (node) { + node.forEach(node => this._removeInputListeners(node)); + } + }; + + toJSON() { + var out = []; + this.fromMap.forEach(function (fn) { + // Don't try to bundle non-named functions that were started via + // model.start directly instead of by name + if (!fn.name) return; + var args = [fn.from].concat(fn.inputPaths); + if (fn.options) args.push(fn.options); + args.push(fn.name); + out.push(args); + }); + return out; + }; +} + +function Fn(model, name, from, inputPaths, fns, options) { + this.model = model.pass({ $fn: this }); + this.name = name; + this.from = from; + this.inputPaths = inputPaths; + this.options = options; + if (!fns) { + throw new TypeError('Model function not found: ' + name); + } + this.getFn = fns.get || fns; + this.setFn = fns.set; + this.fromSegments = from && from.split('.'); + this.inputsSegments = []; + for (var i = 0; i < this.inputPaths.length; i++) { + var segments = this.inputPaths[i].split('.'); + this.inputsSegments.push(segments); + } + + // Copy can be 'output', 'input', 'both', or 'none' + var copy = (options && options.copy) || 'output'; + this.copyInput = (copy === 'input' || copy === 'both'); + this.copyOutput = (copy === 'output' || copy === 'both'); + + // Mode can be 'diffDeep', 'diff', 'arrayDeep', or 'array' + this.mode = (options && options.mode) || 'diffDeep'; + + this.async = !!(options && options.async); + this.eventPending = false; +} + +Fn.prototype.apply = function (fn, inputs) { + for (var i = 0, len = this.inputsSegments.length; i < len; i++) { + var input = this.model._get(this.inputsSegments[i]); + inputs.push(this.copyInput ? util.deepCopy(input) : input); + } + return fn.apply(this.model, inputs); +}; + +Fn.prototype.get = function () { + return this.apply(this.getFn, []); +}; + +Fn.prototype.set = function (value, pass) { + if (!this.setFn) return; + var out = this.apply(this.setFn, [value]); + if (!out) return; + var inputsSegments = this.inputsSegments; + var model = this.model.pass(pass, true); + for (var key in out) { + var value = (this.copyOutput) ? util.deepCopy(out[key]) : out[key]; + this._setValue(model, inputsSegments[key], value); + } +}; + +Fn.prototype.onInput = function (pass) { + if (this.async) { + if (this.eventPending) return; + this.eventPending = true; + var fn = this; + process.nextTick(function () { + fn._onInput(pass); + fn.eventPending = false; + }); + return; + } + return this._onInput(pass); +}; + +Fn.prototype._onInput = function (pass) { + var value = (this.copyOutput) ? util.deepCopy(this.get()) : this.get(); + this._setValue(this.model.pass(pass, true), this.fromSegments, value); + return value; +}; + +Fn.prototype.onOutput = function (pass) { + var value = this.model._get(this.fromSegments); + return this.set(value, pass); +}; + +Fn.prototype._setValue = function (model, segments, value) { + if (this.mode === 'diffDeep') { + model._setDiffDeep(segments, value); + } else if (this.mode === 'arrayDeep') { + model._setArrayDiffDeep(segments, value); + } else if (this.mode === 'array') { + model._setArrayDiff(segments, value); + } else { + model._setDiff(segments, value); + } +}; diff --git a/src/Model/index.ts b/src/Model/index.ts new file mode 100644 index 000000000..9c7e143c0 --- /dev/null +++ b/src/Model/index.ts @@ -0,0 +1,29 @@ +/// +/// + +import { serverRequire } from '../util'; +export { Model, ChildModel, RootModel, type ModelOptions, type UUID, type DefualtType } from './Model'; +export { ModelData } from './collections'; +export { type Subscribable } from './subscriptions'; + +// Extend model on both server and client // +import './unbundle'; +import './events'; +import './paths'; +import './collections'; +import './mutators'; +import './setDiff'; + +import './connection'; +import './subscriptions'; +import './Query'; +import './contexts'; + +import './fn'; +import './filter'; +import './refList'; +import './ref'; + +// Extend model for server // +serverRequire(module, './bundle'); +serverRequire(module, './connection.server'); diff --git a/src/Model/mutators.ts b/src/Model/mutators.ts new file mode 100644 index 000000000..1a4a956cd --- /dev/null +++ b/src/Model/mutators.ts @@ -0,0 +1,1015 @@ +import type { Callback, Path, ArrayItemType, Segments } from '../types'; +import * as util from '../util'; +import { Model } from './Model'; + +var mutationEvents = require('./events').mutationEvents; + +var ChangeEvent = mutationEvents.ChangeEvent; +var InsertEvent = mutationEvents.InsertEvent; +var RemoveEvent = mutationEvents.RemoveEvent; +var MoveEvent = mutationEvents.MoveEvent; +var promisify = util.promisify; + +type ValueCallback = ((error: Error | undefined, value: T) => void); + +declare module './Model' { + interface Model { + _mutate(segments, fn, cb): void; + set(value: T, cb?: ErrorCallback): T | undefined; + set(subpath: Path, value: any, cb?: ErrorCallback): S | undefined; + setPromised(value: T): Promise; + setPromised(subpath: Path, value: any): Promise; + _set(segments: Segments, value: any, cb?: ErrorCallback): S | undefined; + + setNull(value: T, cb?: ErrorCallback): T | undefined; + setNull(subpath: Path, value: S, cb?: ErrorCallback): S | undefined; + setNullPromised(value: T): Promise; + setNullPromised(subpath: Path, value: any): Promise; + _setNull(segments: Segments, value: S, cb?: ErrorCallback): S | undefined; + + setEach(value: any, cb?: ErrorCallback): void; + setEach(subpath: Path, value: any, cb?: ErrorCallback): void; + setEachPromised(value: any): Promise; + setEachPromised(subpath: Path, value: any): Promise; + _setEach(segments: Segments, value: any, cb?: ErrorCallback): void; + + create(value: any, cb?: ErrorCallback): void; + create(subpath: Path, value: any, cb?: ErrorCallback): void; + createPromised(value: any): Promise; + createPromised(subpath: Path, value: any): Promise; + _create(segments: Segments, value: any, cb?: ErrorCallback): void; + + createNull(value: any, cb?: ErrorCallback): void; + createNull(subpath: Path, value: any, cb?: ErrorCallback): void; + createNullPromised(value: any): Promise; + createNullPromised(subpath: Path, value: any): Promise; + _createNull(segments: Segments, value: any, cb?: ErrorCallback): void; + + /** + * Adds a document to the collection, under an id subpath corresponding + * to either the document's `id` property if present, or else a new randomly + * generated id. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @param value - Document to add + * @param cb - Optional callback + */ + add(value: any, cb?: ValueCallback): string; + /** + * Adds a document to the collection, under an id subpath corresponding + * to either the document's `id` property if present, or else a new randomly + * generated id. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @param subpath - Optional Collection under which to add the document + * @param value - Document to add + * @param cb - Optional callback + */ + add(subpath: Path, value: any, cb?: ValueCallback): string; + addPromised(value: any): Promise; + addPromised(subpath: Path, value: any): Promise; + _add(segments: Segments, value: any, cb?: ValueCallback): string; + + /** + * Deletes the value at this relative subpath. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @typeParam S - Type of the data returned by delete operation + * @param subpath + * @returns the old value at the path + */ + del(subpath: Path, cb?: Callback): S | undefined; + /** + * Deletes the value at this model's path. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @typeParam T - Type of the data returned by delete operation + * @returns the old value at the path + */ + del(cb?: Callback): T | undefined; + /** + * Deletes the value at this relative subpath. If not provided deletes at model path + * + * Promise resolves when commit succeeds, rejected on commit failure. + * + * @param subpath - Optional subpath to delete + * @returns promise + */ + delPromised(subpath?: Path): Promise; + _del(segments: Segments, cb?: ErrorCallback): S; + + _delNoDereference(segments: Segments, cb?: ErrorCallback): void; + + increment(value?: number): number; + increment(subpath: Path, value?: number, cb?: ErrorCallback): number; + incrementPromised(value?: number): Promise; + incrementPromised(subpath: Path, value?: number): Promise; + _increment(segments: Segments, value: number, cb?: ErrorCallback): number; + + /** + * Push a value to a model array + * + * @param value + * @returns the length of the array + */ + push(value: any): number; + /** + * Push a value to a model array at subpath + * + * @param subpath + * @param value + * @returns the length of the array + */ + push(subpath: Path, value: any, cb?: ErrorCallback): number; + pushPromised(value: any): Promise; + pushPromised(subpath: Path, value: any): Promise; + _push(segments: Segments, value: any, cb?: ErrorCallback): number; + + unshift(value: any): void; + unshift(subpath: Path, value: any, cb?: ErrorCallback): void; + unshiftPromised(value: any): Promise; + unshiftPromised(subpath: Path, value: any): Promise; + _unshift(segments: Segments, value: any, cb?: ErrorCallback): void; + + insert(index: number, value: any): void; + insert(subpath: Path, index: number, value: any, cb?: ErrorCallback): void; + insertPromised(value: any, index: number): Promise; + insertPromised(subpath: Path, index: number, value: any): Promise; + _insert(segments: Segments, index: number, value: any, cb?: ErrorCallback): void; + + /** + * Removes an item from the end of the array at this model's path or a + * relative subpath. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * @typeParam V - type of data at subpath + * @param subpath + * @returns the removed item + */ + pop(subpath: Path, cb?: Callback): V | undefined; + pop>(cb?: Callback): V | undefined; + popPromised(value: any): Promise; + popPromised(subpath: Path, value: any): Promise; + _pop(segments: Segments, value: any, cb?: ErrorCallback): void; + + shift(subpath?: Path, cb?: ErrorCallback): S; + shiftPromised(subpath?: Path): Promise; + _shift(segments: Segments, cb?: ErrorCallback): S; + + /** + * Removes one or more items from the array at this model's path or a + * relative subpath. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @typeParam - Type of data targeted by remove operation + * @param subpath - Subpath to remove + * @param index - 0-based index at which to start removing items + * @param howMany - Number of items to remove. Defaults to `1`. + * @returns array of the removed items + */ + remove(subpath: Path, index: number, howMany?: number, cb?: Callback): V[]; + // Calling `remove(n)` with one argument on a model pointing to a + // non-array results in `N` being `never`, but it still compiles. Is + // there a way to disallow that? + remove>(index: number, howMany?: number, cb?: Callback): V[]; + removePromised(index: number): Promise; + removePromised(subpath: Path): Promise; + removePromised(index: number, howMany: number): Promise; + removePromised(subpath: Path, index: number): Promise; + removePromised(subpath: Path, index: number, howMany: number): void; + _remove(segments: Segments, index: number, howMany: number, cb?: ErrorCallback): void; + + move(from: number, to: number, cb?: ErrorCallback): void; + move(from: number, to: number, howMany: number, cb?: ErrorCallback): void; + move(subpath: Path, from: number, to: number, cb?: ErrorCallback): void; + move(subpath: Path, from: number, to: number, howmany: number, cb?: ErrorCallback): void; + movePromised(from: number, to: number): Promise; + movePromised(from: number, to: number, howMany: number): Promise; + movePromised(subpath: Path, from: number, to: number): Promise; + movePromised(subpath: Path, from: number, to: number, howmany: number): Promise; + _move(segments: Segments, from: number, to: number, owMany: number, cb?: ErrorCallback): void; + + stringInsert(index: number, text: string, cb?: ErrorCallback): void; + stringInsert(subpath: Path, index: number, text: string, cb?: ErrorCallback): void; + stringInsertPromised(index: number, text: string): Promise; + stringInsertPromised(subpath: Path, index: number, text: string): Promise; + _stringInsert(segments: Segments, index: number,text: string, cb?: ErrorCallback): void; + + stringRemove(index: number, howMany: number, cb?: ErrorCallback): void; + stringRemove(subpath: Path, index: number, cb?: ErrorCallback): void; + stringRemove(subpath: Path, index: number, howMany: number, cb?: ErrorCallback): void; + stringRemovePromised(index: number, howMany: number): Promise; + stringRemovePromised(subpath: Path, index: number): Promise; + stringRemovePromised(subpath: Path, index: number, howMany: number): Promise; + _stringRemove(segments: Segments, index: number, howMany: number, cb?: ErrorCallback): void; + + subtypeSubmit(subtype: any, subtypeOp: any, cb?: ErrorCallback): void; + subtypeSubmit(subpath: Path, subtype: any, subtypeOp: any, cb?: ErrorCallback): void; + subtypeSubmitPromised(subtype: any, subtypeOp: any): Promise; + subtypeSubmitPromised(subpath: Path, subtype: any, subtypeOp: any): Promise; + _subtypeSubmit(segments: Segments, subtype: any, subtypeOp: any, cb?: ErrorCallback): void; + } +} + +Model.prototype._mutate = function(segments, fn, cb) { + cb = this.wrapCallback(cb); + var collectionName = segments[0]; + var id = segments[1]; + if (!collectionName || !id) { + var message = fn.name + ' must be performed under a collection ' + + 'and document id. Invalid path: ' + segments.join('.'); + return cb(new Error(message)); + } + var doc = this.getOrCreateDoc(collectionName, id); + var docSegments = segments.slice(2); + if (this._preventCompose && doc.shareDoc) { + var oldPreventCompose = doc.shareDoc.preventCompose; + doc.shareDoc.preventCompose = true; + var out = fn(doc, docSegments, cb); + doc.shareDoc.preventCompose = oldPreventCompose; + return out; + } + return fn(doc, docSegments, cb); +}; + +Model.prototype.set = function() { + var subpath, value, cb; + if (arguments.length === 1) { + value = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + value = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._set(segments, value, cb); +}; +Model.prototype.setPromised = promisify(Model.prototype.set); + +Model.prototype._set = function(segments, value, cb) { + segments = this._dereference(segments); + var model = this; + function set(doc, docSegments, fnCb) { + var previous = doc.set(docSegments, value, fnCb); + // On setting the entire doc, remote docs sometimes do a copy to add the + // id without it being stored in the database by ShareJS + if (docSegments.length === 0) value = doc.get(docSegments); + var event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + return previous; + } + return this._mutate(segments, set, cb); +}; + +Model.prototype.setNull = function() { + var subpath, value, cb; + if (arguments.length === 1) { + value = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + value = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._setNull(segments, value, cb); +}; +Model.prototype.setNullPromised = promisify(Model.prototype.setNull); + +Model.prototype._setNull = function(segments, value, cb) { + segments = this._dereference(segments); + var model = this; + function setNull(doc, docSegments, fnCb) { + var previous = doc.get(docSegments); + if (previous != null) { + fnCb(); + return previous; + } + doc.set(docSegments, value, fnCb); + var event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + return value; + } + return this._mutate(segments, setNull, cb); +}; + +Model.prototype.setEach = function() { + var subpath, object, cb; + if (arguments.length === 1) { + object = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + object = arguments[1]; + } else { + subpath = arguments[0]; + object = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._setEach(segments, object, cb); +}; +Model.prototype.setEachPromised = promisify(Model.prototype.setEach); + +Model.prototype._setEach = function(segments, object, cb) { + segments = this._dereference(segments); + var group = util.asyncGroup(this.wrapCallback(cb)); + for (var key in object) { + var value = object[key]; + this._set(segments.concat(key), value, group()); + } +}; + +Model.prototype.create = function() { + var subpath, value, cb; + if (arguments.length === 0) { + value = {}; + } else if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + value = {}; + cb = arguments[0]; + } else { + value = arguments[0]; + } + } else if (arguments.length === 2) { + if (typeof arguments[1] === 'function') { + value = arguments[0]; + cb = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + } + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + const segments = this._splitPath(subpath); + this._create(segments, value, cb); +}; +Model.prototype.createPromised = promisify(Model.prototype.create); + +Model.prototype._create = function(segments, value, cb) { + segments = this._dereference(segments); + if (segments.length !== 2) { + var message = 'create may only be used on a document path. ' + + 'Invalid path: ' + segments.join('.'); + cb = this.wrapCallback(cb); + return cb(new Error(message)); + } + var model = this; + function create(doc, docSegments, fnCb) { + var previous; + doc.create(value, fnCb); + // On creating the doc, remote docs do a copy to add the id without + // it being stored in the database by ShareJS + value = doc.get(); + var event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + } + this._mutate(segments, create, cb); +}; + +Model.prototype.createNull = function() { + var subpath, value, cb; + if (arguments.length === 0) { + value = {}; + } else if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + value = {}; + cb = arguments[0]; + } else { + value = arguments[0]; + } + } else if (arguments.length === 2) { + if (typeof arguments[1] === 'function') { + value = arguments[0]; + cb = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + } + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + this._createNull(segments, value, cb); +}; +Model.prototype.createNullPromised = promisify(Model.prototype.createNull); + +Model.prototype._createNull = function(segments, value, cb) { + segments = this._dereference(segments); + var doc = this.getDoc(segments[0], segments[1]); + if (doc && doc.get() != null) return; + this._create(segments, value, cb); +}; + +Model.prototype.add = function() { + var subpath, value, cb; + if (arguments.length === 0) { + value = {}; + } else if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + value = {}; + cb = arguments[0]; + } else { + value = arguments[0]; + } + } else if (arguments.length === 2) { + if (typeof arguments[1] === 'function') { + // (value, callback) + value = arguments[0]; + cb = arguments[1]; + } else if (typeof arguments[0] === 'string' && typeof arguments[1] === 'object') { + // (path, value) + subpath = arguments[0]; + value = arguments[1]; + } else { + // (value, null) + value = arguments[0]; + cb = arguments[1]; + } + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._add(segments, value, cb); +}; +Model.prototype.addPromised = promisify(Model.prototype.add); + +Model.prototype._add = function(segments, value, cb) { + if (typeof value !== 'object') { + let message = 'add requires an object value. Invalid value: ' + value; + const errorCallback = this.wrapCallback(cb); + errorCallback(new Error(message)); + return; + } + + const id = value.id || this.id(); + value.id = id; + segments = this._dereference(segments.concat(id)); + const model = this; + + function add(doc, docSegments, fnCb) { + let previous; + if (docSegments.length) { + previous = doc.set(docSegments, value, fnCb); + } else { + doc.create(value, fnCb); + // On creating the doc, remote docs do a copy to add the id without + // it being stored in the database by ShareJS + value = doc.get(); + } + const event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + } + + const callbackWithId = (cb != null) + ? (err: Error) => { cb(err, id); } + : null; + + this._mutate(segments, add, callbackWithId); + return id; +}; + +Model.prototype.del = function() { + var subpath, cb; + if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + cb = arguments[0]; + } else { + subpath = arguments[0]; + } + } else { + subpath = arguments[0]; + cb = arguments[1]; + } + var segments = this._splitPath(subpath); + return this._del(segments, cb); +}; + +Model.prototype.delPromised = promisify(Model.prototype.del); + +Model.prototype._del = function(segments, cb) { + segments = this._dereference(segments); + return this._delNoDereference(segments, cb); +}; + +Model.prototype._delNoDereference = function(segments, cb) { + var model = this; + function del(doc, docSegments, fnCb) { + var previous = doc.del(docSegments, fnCb); + // When deleting an entire document, also remove the reference to the + // document object from its collection + if (segments.length === 2) { + var collectionName = segments[0]; + var id = segments[1]; + model.root.collections[collectionName].remove(id); + } + var event = new ChangeEvent(undefined, previous, model._pass); + model._emitMutation(segments, event); + return previous; + } + return this._mutate(segments, del, cb); +}; + +Model.prototype.increment = function() { + var subpath, byNumber, cb; + if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + cb = arguments[0]; + } else if (typeof arguments[0] === 'number') { + byNumber = arguments[0]; + } else { + subpath = arguments[0]; + } + } else if (arguments.length === 2) { + if (typeof arguments[1] === 'function') { + cb = arguments[1]; + if (typeof arguments[0] === 'number') { + byNumber = arguments[0]; + } else { + subpath = arguments[0]; + } + } else { + subpath = arguments[0]; + byNumber = arguments[1]; + } + } else { + subpath = arguments[0]; + byNumber = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._increment(segments, byNumber, cb); +}; +Model.prototype.incrementPromised = promisify(Model.prototype.increment); + +Model.prototype._increment = function(segments, byNumber, cb) { + segments = this._dereference(segments); + if (byNumber == null) byNumber = 1; + var model = this; + function increment(doc, docSegments, fnCb) { + var value = doc.increment(docSegments, byNumber, fnCb); + var previous = value - byNumber; + var event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + return value; + } + return this._mutate(segments, increment, cb); +}; + +Model.prototype.push = function() { + var subpath, value, cb; + if (arguments.length === 1) { + value = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + value = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._push(segments, value, cb); +}; +Model.prototype.pushPromised = promisify(Model.prototype.push); + +Model.prototype._push = function(segments, value, cb) { + var forArrayMutator = true; + segments = this._dereference(segments, forArrayMutator); + var model = this; + function push(doc, docSegments, fnCb) { + var length = doc.push(docSegments, value, fnCb); + var event = new InsertEvent(length - 1, [value], model._pass); + model._emitMutation(segments, event); + return length; + } + return this._mutate(segments, push, cb); +}; + +Model.prototype.unshift = function() { + var subpath, value, cb; + if (arguments.length === 1) { + value = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + value = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._unshift(segments, value, cb); +}; +Model.prototype.unshiftPromised = promisify(Model.prototype.unshift); + +Model.prototype._unshift = function(segments, value, cb) { + var forArrayMutator = true; + segments = this._dereference(segments, forArrayMutator); + var model = this; + function unshift(doc, docSegments, fnCb) { + var length = doc.unshift(docSegments, value, fnCb); + var event = new InsertEvent(0, [value], model._pass); + model._emitMutation(segments, event); + return length; + } + return this._mutate(segments, unshift, cb); +}; + +Model.prototype.insert = function() { + var subpath, index, values, cb; + if (arguments.length < 2) { + throw new Error('Not enough arguments for insert'); + } else if (arguments.length === 2) { + index = arguments[0]; + values = arguments[1]; + } else if (arguments.length === 3) { + subpath = arguments[0]; + index = arguments[1]; + values = arguments[2]; + } else { + subpath = arguments[0]; + index = arguments[1]; + values = arguments[2]; + cb = arguments[3]; + } + var segments = this._splitPath(subpath); + return this._insert(segments, +index, values, cb); +}; +Model.prototype.insertPromised = promisify(Model.prototype.insert); + +Model.prototype._insert = function(segments, index, values, cb) { + var forArrayMutator = true; + segments = this._dereference(segments, forArrayMutator); + var model = this; + function insert(doc, docSegments, fnCb) { + var inserted = (Array.isArray(values)) ? values : [values]; + var length = doc.insert(docSegments, index, inserted, fnCb); + var event = new InsertEvent(index, inserted, model._pass); + model._emitMutation(segments, event); + return length; + } + return this._mutate(segments, insert, cb); +}; + +Model.prototype.pop = function() { + var subpath, cb; + if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + cb = arguments[0]; + } else { + subpath = arguments[0]; + } + } else { + subpath = arguments[0]; + cb = arguments[1]; + } + var segments = this._splitPath(subpath); + return this._pop(segments, cb); +}; +Model.prototype.popPromised = promisify(Model.prototype.pop); + +Model.prototype._pop = function(segments, cb) { + var forArrayMutator = true; + segments = this._dereference(segments, forArrayMutator); + var model = this; + function pop(doc, docSegments, fnCb) { + var arr = doc.get(docSegments); + var length = arr && arr.length; + if (!length) { + fnCb(); + return; + } + var value = doc.pop(docSegments, fnCb); + var event = new RemoveEvent(length - 1, [value], model._pass); + model._emitMutation(segments, event); + return value; + } + return this._mutate(segments, pop, cb); +}; + +Model.prototype.shift = function() { + var subpath, cb; + if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + cb = arguments[0]; + } else { + subpath = arguments[0]; + } + } else { + subpath = arguments[0]; + cb = arguments[1]; + } + var segments = this._splitPath(subpath); + return this._shift(segments, cb); +}; +Model.prototype.shiftPromised = promisify(Model.prototype.shift); + +Model.prototype._shift = function(segments, cb) { + var forArrayMutator = true; + segments = this._dereference(segments, forArrayMutator); + var model = this; + function shift(doc, docSegments, fnCb) { + var arr = doc.get(docSegments); + var length = arr && arr.length; + if (!length) { + fnCb(); + return; + } + var value = doc.shift(docSegments, fnCb); + var event = new RemoveEvent(0, [value], model._pass); + model._emitMutation(segments, event); + return value; + } + return this._mutate(segments, shift, cb); +}; + +Model.prototype.remove = function() { + var subpath, index, howMany, cb; + if (arguments.length < 2) { + index = arguments[0]; + } else if (arguments.length === 2) { + if (typeof arguments[1] === 'function') { + cb = arguments[1]; + if (typeof arguments[0] === 'number') { + index = arguments[0]; + } else { + subpath = arguments[0]; + } + } else { + // eslint-disable-next-line no-lonely-if + if (typeof arguments[0] === 'number') { + index = arguments[0]; + howMany = arguments[1]; + } else { + subpath = arguments[0]; + index = arguments[1]; + } + } + } else if (arguments.length === 3) { + if (typeof arguments[2] === 'function') { + cb = arguments[2]; + if (typeof arguments[0] === 'number') { + index = arguments[0]; + howMany = arguments[1]; + } else { + subpath = arguments[0]; + index = arguments[1]; + } + } else { + subpath = arguments[0]; + index = arguments[1]; + howMany = arguments[2]; + } + } else { + subpath = arguments[0]; + index = arguments[1]; + howMany = arguments[2]; + cb = arguments[3]; + } + var segments = this._splitPath(subpath); + if (index == null) index = segments.pop(); + return this._remove(segments, +index, howMany, cb); +}; +Model.prototype.removePromised = promisify(Model.prototype.remove); + +Model.prototype._remove = function(segments, index, howMany, cb) { + var forArrayMutator = true; + segments = this._dereference(segments, forArrayMutator); + if (howMany == null) howMany = 1; + var model = this; + function remove(doc, docSegments, fnCb) { + var removed = doc.remove(docSegments, index, howMany, fnCb); + var event = new RemoveEvent(index, removed, model._pass); + model._emitMutation(segments, event); + return removed; + } + return this._mutate(segments, remove, cb); +}; + +Model.prototype.move = function() { + var subpath, from, to, howMany, cb; + if (arguments.length < 2) { + throw new Error('Not enough arguments for move'); + } else if (arguments.length === 2) { + from = arguments[0]; + to = arguments[1]; + } else if (arguments.length === 3) { + if (typeof arguments[2] === 'function') { + from = arguments[0]; + to = arguments[1]; + cb = arguments[2]; + } else if (typeof arguments[0] === 'number') { + from = arguments[0]; + to = arguments[1]; + howMany = arguments[2]; + } else { + subpath = arguments[0]; + from = arguments[1]; + to = arguments[2]; + } + } else if (arguments.length === 4) { + if (typeof arguments[3] === 'function') { + cb = arguments[3]; + if (typeof arguments[0] === 'number') { + from = arguments[0]; + to = arguments[1]; + howMany = arguments[2]; + } else { + subpath = arguments[0]; + from = arguments[1]; + to = arguments[2]; + } + } else { + subpath = arguments[0]; + from = arguments[1]; + to = arguments[2]; + howMany = arguments[3]; + } + } else { + subpath = arguments[0]; + from = arguments[1]; + to = arguments[2]; + howMany = arguments[3]; + cb = arguments[4]; + } + var segments = this._splitPath(subpath); + return this._move(segments, from, to, howMany, cb); +}; +Model.prototype.movePromised = promisify(Model.prototype.move); + +Model.prototype._move = function(segments, from, to, howMany, cb) { + var forArrayMutator = true; + segments = this._dereference(segments, forArrayMutator); + if (howMany == null) howMany = 1; + var model = this; + function move(doc, docSegments, fnCb) { + // Cast to numbers + from = +from; + to = +to; + // Convert negative indices into positive + if (from < 0 || to < 0) { + var len = doc.get(docSegments).length; + if (from < 0) from += len; + if (to < 0) to += len; + } + var moved = doc.move(docSegments, from, to, howMany, fnCb); + var event = new MoveEvent(from, to, moved.length, model._pass); + model._emitMutation(segments, event); + return moved; + } + return this._mutate(segments, move, cb); +}; + +Model.prototype.stringInsert = function() { + var subpath, index, text, cb; + if (arguments.length < 2) { + throw new Error('Not enough arguments for stringInsert'); + } else if (arguments.length === 2) { + index = arguments[0]; + text = arguments[1]; + } else if (arguments.length === 3) { + if (typeof arguments[2] === 'function') { + index = arguments[0]; + text = arguments[1]; + cb = arguments[2]; + } else { + subpath = arguments[0]; + index = arguments[1]; + text = arguments[2]; + } + } else { + subpath = arguments[0]; + index = arguments[1]; + text = arguments[2]; + cb = arguments[3]; + } + var segments = this._splitPath(subpath); + return this._stringInsert(segments, index, text, cb); +}; +Model.prototype.stringInsertPromised = promisify(Model.prototype.stringInsert); + +Model.prototype._stringInsert = function(segments, index, text, cb) { + segments = this._dereference(segments); + var model = this; + function stringInsert(doc, docSegments, fnCb) { + var previous = doc.stringInsert(docSegments, index, text, fnCb); + var value = doc.get(docSegments); + var pass = model.pass({$stringInsert: {index: index, text: text}})._pass; + var event = new ChangeEvent(value, previous, pass); + model._emitMutation(segments, event); + return; + } + return this._mutate(segments, stringInsert, cb); +}; + +Model.prototype.stringRemove = function() { + var subpath, index, howMany, cb; + if (arguments.length < 2) { + throw new Error('Not enough arguments for stringRemove'); + } else if (arguments.length === 2) { + index = arguments[0]; + howMany = arguments[1]; + } else if (arguments.length === 3) { + if (typeof arguments[2] === 'function') { + index = arguments[0]; + howMany = arguments[1]; + cb = arguments[2]; + } else { + subpath = arguments[0]; + index = arguments[1]; + howMany = arguments[2]; + } + } else { + subpath = arguments[0]; + index = arguments[1]; + howMany = arguments[2]; + cb = arguments[3]; + } + var segments = this._splitPath(subpath); + return this._stringRemove(segments, index, howMany, cb); +}; +Model.prototype.stringRemovePromised = promisify(Model.prototype.stringRemove); + +Model.prototype._stringRemove = function(segments, index, howMany, cb) { + segments = this._dereference(segments); + var model = this; + function stringRemove(doc, docSegments, fnCb) { + var previous = doc.stringRemove(docSegments, index, howMany, fnCb); + var value = doc.get(docSegments); + var pass = model.pass({$stringRemove: {index: index, howMany: howMany}})._pass; + var event = new ChangeEvent(value, previous, pass); + model._emitMutation(segments, event); + return; + } + return this._mutate(segments, stringRemove, cb); +}; + +Model.prototype.subtypeSubmit = function() { + var subpath, subtype, subtypeOp, cb; + if (arguments.length < 2) { + throw new Error('Not enough arguments for subtypeSubmit'); + } else if (arguments.length === 2) { + subtype = arguments[0]; + subtypeOp = arguments[1]; + } else if (arguments.length === 3) { + if (typeof arguments[2] === 'function') { + subtype = arguments[0]; + subtypeOp = arguments[1]; + cb = arguments[2]; + } else { + subpath = arguments[0]; + subtype = arguments[1]; + subtypeOp = arguments[2]; + } + } else { + subpath = arguments[0]; + subtype = arguments[1]; + subtypeOp = arguments[2]; + cb = arguments[3]; + } + var segments = this._splitPath(subpath); + return this._subtypeSubmit(segments, subtype, subtypeOp, cb); +}; +Model.prototype.subtypeSubmitPromised = promisify(Model.prototype.subtypeSubmit); + +Model.prototype._subtypeSubmit = function(segments, subtype, subtypeOp, cb) { + segments = this._dereference(segments); + var model = this; + function subtypeSubmit(doc, docSegments, fnCb) { + var previous = doc.subtypeSubmit(docSegments, subtype, subtypeOp, fnCb); + var value = doc.get(docSegments); + var pass = model.pass({$subtype: {type: subtype, op: subtypeOp}})._pass; + // Emit undefined for the previous value, since we don't really know + // whether or not the previous value returned by the subtypeSubmit is the + // same object returned by reference or not. This may cause change + // listeners to over-trigger, but that is usually going to be better than + // under-triggering + var event = new ChangeEvent(value, undefined, pass); + model._emitMutation(segments, event); + return previous; + } + return this._mutate(segments, subtypeSubmit, cb); +}; diff --git a/src/Model/paths.ts b/src/Model/paths.ts new file mode 100644 index 000000000..43dada9c1 --- /dev/null +++ b/src/Model/paths.ts @@ -0,0 +1,156 @@ +import { ChildModel, Model } from './Model'; +import type { Path, PathLike } from '../types'; +import { ModelData } from './collections'; + +exports.mixin = {}; + +declare module './Model' { + interface Model { + /** + * Returns a ChildModel scoped to the root path. + */ + at(): ChildModel; + + /** + * Returns a ChildModel scoped to a relative subpath under this model's path. + * + * @typeParam S - type of data at subpath + * @param subpath + */ + at(subpath: PathLike): ChildModel; + + /** + * Check if subpath is a PathLike + * + * @param subpath + * @returns boolean + */ + isPath(subpath: PathLike): boolean; + + leaf(path: string): string; + + /** + * Get the parent {levels} up from current model or root model + * @param levels - number of levels to traverse the tree + * @returns parent or root model + */ + parent(levels?: number): Model; + + /** + * Get full path to given subpath + * + * @param subpath - PathLike subpath + */ + path(subpath?: PathLike): string; + + /** + * Returns a ChildModel scoped to the root path. + * + * @returns ChildModel + */ + scope(): ChildModel; + + /** + * Returns a ChildModel scoped to an absolute path. + * + * @typeParam S - Type of data at subpath + * @param subpath - Path of GhildModel to scope to + * @returns ChildModel + */ + scope(subpath: Path): ChildModel; + + /** @private */ + _splitPath(subpath: PathLike): string[]; + } +} + +Model.prototype._splitPath = function(subpath?: PathLike): string[] { + var path = this.path(subpath); + return (path && path.split('.')) || []; +}; + +/** + * Returns the path equivalent to the path of the current scoped model plus + * (optionally) a suffix subpath + * + * @optional @param {String} subpath + * @return {String} absolute path + * @api public + */ +Model.prototype.path = function(subpath?: PathLike): string { + if (subpath == null || subpath === '') return (this._at) ? this._at : ''; + if (typeof subpath === 'string' || typeof subpath === 'number') { + return (this._at) ? this._at + '.' + subpath : '' + subpath; + } + if (typeof subpath.path === 'function') return subpath.path(); +}; + +Model.prototype.isPath = function(subpath?: PathLike): boolean { + return this.path(subpath) != null; +}; + +Model.prototype.scope = function(path?: PathLike): ChildModel { + if (arguments.length > 1) { + for (var i = 1; i < arguments.length; i++) { + path = path + '.' + arguments[i]; + } + } + return createScoped(this, path); +}; + +/** + * Create a model object scoped to a particular path. + * Example: + * var user = model.at('users.1'); + * user.set('username', 'brian'); + * user.on('push', 'todos', function(todo) { + * // ... + * }); + * + * @param {String} segment + * @return {Model} a scoped model + * @api public + */ +Model.prototype.at = function(subpath?: Path) { + if (arguments.length > 1) { + for (var i = 1; i < arguments.length; i++) { + subpath = subpath + '.' + arguments[i]; + } + } + var path = this.path(subpath); + return createScoped(this, path); +}; + +function createScoped(model, path) { + var scoped = model._child(); + scoped._at = path; + return scoped; +} + +/** + * Returns a model scope that is a number of levels above the current scoped + * path. Number of levels defaults to 1, so this method called without + * arguments returns the model scope's parent model scope. + * + * @optional @param {Number} levels + * @return {Model} a scoped model + */ +Model.prototype.parent = function(levels) { + if (levels == null) levels = 1; + var segments = this._splitPath(); + var len = Math.max(0, segments.length - levels); + var path = segments.slice(0, len).join('.'); + return this.scope(path); +}; + +/** + * Returns the last property segment of the current model scope path + * + * @optional @param {String} path + * @return {String} + */ +Model.prototype.leaf = function(path) { + if (!path) path = this.path(); + var i = path.lastIndexOf('.'); + return path.slice(i + 1); +}; diff --git a/src/Model/ref.ts b/src/Model/ref.ts new file mode 100644 index 000000000..a9ebfaf96 --- /dev/null +++ b/src/Model/ref.ts @@ -0,0 +1,398 @@ +import { EventListenerTree } from './EventListenerTree'; +import { EventMapTree } from './EventMapTree'; +import { Model } from './Model'; +import { type Filter } from './filter'; +import { type Query } from './Query'; +import type { Path, PathLike, Segments } from '../types'; +import { type RefListOptions } from './refList'; + +type Refable = string | number | Model | Query | Filter; + +export interface RefOptions { + /** + * If true, indicies will be updated. + */ + updateIndices: boolean; +} + +declare module './Model' { + interface Model { + /** + * Creates an array at `outputPath` that consists of references to all the + * objects at `collectionPath` that have ids matching the ids at `idsPath`. + * The array is automatically updated based on changes to the input paths. + * + * @param outputPath - Path at which to create the ref list. This must be + * under a local collection, typically `'_page'` or a component model. + * @param collectionPath - Path to a Racer collection or a collection-like + * object, where each id string key maps to an object value with matching + * `id` property. + * @param idsPath - Path to an array of string ids + * @param options - Optional + * + * @see https://derbyjs.github.io/derby/models/refs + */ + refList(outputPath: PathLike, collectionPath: PathLike, idsPath: PathLike, options?: RefListOptions): ChildModel; + + _canRefTo(value: Refable): boolean; + // _canRefTo(from: Segments, to: Segments, options: RefOptions): boolean; + + /** + * Creates a reference for this model pointing to another path `to`. Like a + * symlink, any reads/writes on this `to` ref will work as if they were done on + * `path` directly. + * + * @param to - Location that the reference points to + * @return a model scoped to `path` + * + * @see https://derbyjs.github.io/derby/models/refs + */ + ref(to: Refable): ChildModel; + /** + * Creates a reference at `path` pointing to another path `to`. Like a + * symlink, any reads/writes on `path` will work as if they were done on + * `path` directly. + * + * @param path - Location at which to create the reference. This must be + * under a local collection, typically `'_page'` or a component model. + * @param to - Location that the reference points to + * @params options - Optional {@link RefOptions} + * @return a model scoped to `path` + * + * @see https://derbyjs.github.io/derby/models/refs + */ + ref(path: PathLike, to: Refable, options?: RefOptions): ChildModel; + _ref(from: Segments, to: Segments, options?: RefOptions): void; + + /** + * Removes a model reference. + * + * @param path - Location of the reference to remove + * + * @see https://derbyjs.github.io/derby/models/refs + */ + removeRef(path: PathLike): void; + _removeRef(segments: Segments): void; + + removeAllRefs(subpath: PathLike): void; + _removeAllRefs(segments: Segments): void; + + dereference(subpath: Path): Segments; + _dereference(segments: Segments, forArrayMutator: any, ignore: boolean): Segments; + + _refs: any; + _refLists: any; + } +} + + +Model.INITS.push(function(model) { + var root = model.root; + root._refs = new Refs(); + addIndexListeners(root); + addListener(root, 'changeImmediate', refChange); + addListener(root, 'loadImmediate', refLoad); + addListener(root, 'unloadImmediate', refUnload); + addListener(root, 'insertImmediate', refInsert); + addListener(root, 'removeImmediate', refRemove); + addListener(root, 'moveImmediate', refMove); +}); + +function addIndexListeners(model) { + model.on('insertImmediate', function refInsertIndex(segments, event) { + var index = event.index; + var howMany = event.values.length; + function patchInsert(refIndex) { + return (index <= refIndex) ? refIndex + howMany : refIndex; + } + onIndexChange(segments, patchInsert); + }); + model.on('removeImmediate', function refRemoveIndex(segments, event) { + var index = event.index; + var howMany = event.values.length; + function patchRemove(refIndex) { + return (index <= refIndex) ? refIndex - howMany : refIndex; + } + onIndexChange(segments, patchRemove); + }); + model.on('moveImmediate', function refMoveIndex(segments, event) { + var from = event.from; + var to = event.to; + var howMany = event.howMany; + function patchMove(refIndex) { + // If the index was moved itself + if (from <= refIndex && refIndex < from + howMany) { + return refIndex + to - from; + } + // Remove part of a move + if (from <= refIndex) refIndex -= howMany; + // Insert part of a move + if (to <= refIndex) refIndex += howMany; + return refIndex; + } + onIndexChange(segments, patchMove); + }); + function onIndexChange(segments, patch) { + var toListeners = model._refs.toListeners; + var refs = toListeners.getDescendantListeners(segments); + for (var i = 0; i < refs.length; i++) { + var ref = refs[i]; + if (!ref.updateIndices) continue; + var index = +ref.toSegments[segments.length]; + var patched = patch(index); + if (index === patched) continue; + toListeners.removeListener(ref.toSegments, ref); + ref.toSegments[segments.length] = '' + patched; + ref.to = ref.toSegments.join('.'); + toListeners.addListener(ref.toSegments, ref); + } + } +} + +function refChange(model, dereferenced, event, segments) { + var value = event.value; + // Detect if we are deleting vs. setting to undefined + if (value === undefined) { + var parentSegments = segments.slice(); + var last = parentSegments.pop(); + var parent = model._get(parentSegments); + if (!parent || !(last in parent)) { + model._del(dereferenced); + return; + } + } + model._set(dereferenced, value); +} +function refLoad(model, dereferenced, event) { + model._set(dereferenced, event.value); +} +function refUnload(model, dereferenced) { + model._del(dereferenced); +} +function refInsert(model, dereferenced, event) { + model._insert(dereferenced, event.index, event.values); +} +function refRemove(model, dereferenced, event) { + model._remove(dereferenced, event.index, event.values.length); +} +function refMove(model, dereferenced, event) { + model._move(dereferenced, event.from, event.to, event.howMany); +} + +function addListener(model, type, fn) { + model.on(type, refListener); + function refListener(segments, event) { + var passed = event.passed; + // Find cases where an event is emitted on a path where a reference + // is pointing. All original mutations happen on the fully dereferenced + // location, so this detection only needs to happen in one direction + var node = model._refs.toListeners; + for (var i = 0; i < segments.length; i++) { + var segment = segments[i]; + node = node.children && node.children.values[segment]; + if (!node) return; + // If a ref is found pointing to a matching subpath, re-emit on the + // place where the reference is coming from as if the mutation also + // occured at that path + var refs = node.listeners; + if (!refs) continue; + + // Shallow clone refs in case a ref is removed while going through + // the loop + refs = refs.slice(); + var remaining = segments.slice(i + 1); + for (var refIndex = 0; refIndex < refs.length; refIndex++) { + var ref = refs[refIndex]; + var dereferenced = ref.fromSegments.concat(remaining); + // The value may already be up to date via object reference. If so, + // simply re-emit the event. Otherwise, perform the same mutation on + // the ref's path + if (model._get(dereferenced) === model._get(segments)) { + model._emitMutation(dereferenced, event); + } else { + var setterModel = model.pass(passed); + setterModel._dereference = noopDereference; + fn(setterModel, dereferenced, event, segments); + } + } + } + // If a ref points to a child of a matching subpath, get the value in + // case it has changed and set if different + var refs = node.getOwnDescendantListeners(); + for (var i = 0; i < refs.length; i++) { + var ref = refs[i]; + var value = model._get(ref.toSegments); + var previous = model._get(ref.fromSegments); + if (previous !== value) { + var setterModel = model.pass(passed); + setterModel._dereference = noopDereference; + setterModel._set(ref.fromSegments, value); + } + } + } +} + +Model.prototype._canRefTo = function(value) { + return this.isPath(value) || (value && typeof (value as any).ref === 'function'); +}; + +Model.prototype.ref = function() { + var from, to, options; + // to could be pathlike, model, query, or filter + if (arguments.length === 1) { + to = arguments[0]; + } else if (arguments.length === 2) { + if (this._canRefTo(arguments[1])) { + from = arguments[0]; + to = arguments[1]; + } else { + to = arguments[0]; + options = arguments[1]; + } + } else { + from = arguments[0]; + to = arguments[1]; + options = arguments[2]; + } + var fromPath = this.path(from); + var toPath = this.path(to); + // Make ref to reffable object, such as query or filter + if (!toPath) return to.ref(fromPath); + var fromSegments = fromPath.split('.'); + var toSegments = toPath.split('.'); + if (fromSegments.length < 2) { + throw new Error('ref must be performed under a collection ' + + 'and document id. Invalid path: ' + fromPath); + } + this._ref(fromSegments, toSegments, options); + return this.scope(fromPath); +}; + +Model.prototype._ref = function(fromSegments, toSegments, options) { + this.root._refs.remove(fromSegments); + this.root._refLists.remove(fromSegments); + var value = this._get(toSegments); + this._set(fromSegments, value); + var ref = new Ref(fromSegments, toSegments, options); + this.root._refs.add(ref); +}; + +Model.prototype.removeRef = function(subpath) { + var segments = this._splitPath(subpath); + this._removeRef(segments); +}; +Model.prototype._removeRef = function(segments) { + this.root._refs.remove(segments); + this.root._refLists.remove(segments); + this._del(segments); +}; + +Model.prototype.removeAllRefs = function(subpath) { + var segments = this._splitPath(subpath); + this._removeAllRefs(segments); +}; +Model.prototype._removeAllRefs = function(segments) { + this.root._refs.removeAll(segments); + this.root._refLists.removeAll(segments); +}; + +Model.prototype.dereference = function(subpath) { + var segments = this._splitPath(subpath); + return this._dereference(segments).join('.'); +}; + +Model.prototype._dereference = function(segments, forArrayMutator, ignore) { + if (segments.length === 0) return segments; + var doAgain; + do { + var refsNode = this.root._refs.fromMap; + var refListsNode = this.root._refLists.fromMap; + doAgain = false; + for (var i = 0, len = segments.length; i < len; i++) { + // @TODO: resolve type for Segments + var segment = segments[i] as string; + + refsNode = refsNode && refsNode.children && refsNode.children.values[segment]; + var ref = refsNode && refsNode.listener; + if (ref) { + var remaining = segments.slice(i + 1); + segments = ref.toSegments.concat(remaining); + doAgain = true; + break; + } + + refListsNode = refListsNode && refListsNode.children && refListsNode.children.values[segment]; + var refList = refListsNode && refListsNode.listener; + if (refList && refList !== ignore) { + var belowDescendant = i + 2 < len; + var belowChild = i + 1 < len; + if (!(belowDescendant || forArrayMutator && belowChild)) continue; + segments = refList.dereference(segments, i); + doAgain = true; + break; + } + } + } while (doAgain); + // If a dereference fails, return a path that will result in a null value + // instead of a path to everything in the model + if (segments.length === 0) return ['$null']; + return segments; +}; + +function noopDereference(segments) { + return segments; +} + +export class Ref { + fromSegments: Segments; + toSegments: Segments; + updateIndices: boolean; + + constructor(fromSegments: Segments, toSegments: Segments, options?: RefOptions) { + this.fromSegments = fromSegments; + this.toSegments = toSegments; + this.updateIndices = options && options.updateIndices; + } +} + +export class Refs { + fromMap: EventMapTree; + toListeners: EventListenerTree; + + constructor() { + this.fromMap = new EventMapTree(); + this.toListeners = new EventListenerTree(); + } + + _removeInputListeners(ref: Ref) { + this.toListeners.removeListener(ref.toSegments, ref); + }; + + add(ref) { + this.fromMap.setListener(ref.fromSegments, ref); + this.toListeners.addListener(ref.toSegments, ref); + }; + + remove(segments) { + var ref = this.fromMap.deleteListener(segments); + if (!ref) return; + this.toListeners.removeListener(ref.toSegments, ref); + }; + + removeAll(segments) { + var node = this.fromMap.deleteAllListeners(segments); + if (node) { + node.forEach(node => this._removeInputListeners(node)); + } + }; + + toJSON() { + var out = []; + this.fromMap.forEach(function(ref) { + var from = ref.fromSegments.join('.'); + var to = ref.toSegments.join('.'); + out.push([from, to]); + }); + return out; + }; +} diff --git a/lib/Model/refList.js b/src/Model/refList.ts similarity index 53% rename from lib/Model/refList.js rename to src/Model/refList.ts index 72ff6e194..e4cfababf 100644 --- a/lib/Model/refList.js +++ b/src/Model/refList.ts @@ -1,24 +1,58 @@ -var util = require('../util'); -var Model = require('./Model'); +import { EventListenerTree } from './EventListenerTree'; +import { EventMapTree } from './EventMapTree'; +import { Model } from './Model'; + +export interface RefListOptions { + /** + * If true, then objects from the source collection will be deleted if the + * corresponding item is removed from the refList's output path. + */ + deleteRemoved: boolean, +} + +declare module './Model' { + interface Model { + refList(to: any, ids: any, options?: RefListOptions): RefList; + refList(from: any, to: any, ids: any, options?: RefListOptions): RefList; + } +} Model.INITS.push(function(model) { var root = model.root; - root._refLists = new RefLists(root); - for (var type in Model.MUTATOR_EVENTS) { - addListener(root, type); - } + root._refLists = new RefLists(); + addListener(root, 'changeImmediate'); + addListener(root, 'loadImmediate'); + addListener(root, 'unloadImmediate'); + addListener(root, 'insertImmediate'); + addListener(root, 'removeImmediate'); + addListener(root, 'moveImmediate'); }); function addListener(model, type) { model.on(type, refListListener); - function refListListener(segments, eventArgs) { - var pass = eventArgs[eventArgs.length - 1]; + function refListListener(segments, event) { + var passed = event.passed; // Check for updates on or underneath paths - var fromMap = model._refLists.fromMap; - for (var from in fromMap) { - var refList = fromMap[from]; - if (pass.$refList === refList) continue; - refList.onMutation(type, segments, eventArgs); + var refLists = model._refLists.fromMap.getAffectedListeners(segments); + for (var i = 0; i < refLists.length; i++) { + var refList = refLists[i]; + if (passed.$refList !== refList) { + patchFromEvent(segments, event, refList); + } + }; + var refLists = model._refLists.toListeners.getAffectedListeners(segments); + for (var i = 0; i < refLists.length; i++) { + var refList = refLists[i]; + if (passed.$refList !== refList) { + patchToEvent(segments, event, refList); + } + }; + var refLists = model._refLists.idsListeners.getAffectedListeners(segments); + for (var i = 0; i < refLists.length; i++) { + var refList = refLists[i]; + if (passed.$refList !== refList) { + patchIdsEvent(segments, event, refList); + } } } } @@ -26,29 +60,26 @@ function addListener(model, type) { /** * @param {String} type * @param {Array} segments - * @param {Array} eventArgs + * @param {Event} event * @param {RefList} refList */ -function patchFromEvent(type, segments, eventArgs, refList) { +function patchFromEvent(segments, event, refList) { + var type = event.type; var fromLength = refList.fromSegments.length; var segmentsLength = segments.length; - var pass = eventArgs[eventArgs.length - 1]; - var model = refList.model.pass(pass, true); + var model = refList.model.pass(event.passed, true); // Mutation on the `from` output itself if (segmentsLength === fromLength) { if (type === 'insert') { - var index = eventArgs[0]; - var values = eventArgs[1]; - var ids = setNewToValues(model, refList, values); - model._insert(refList.idsSegments, index, ids); + const ids = setNewToValues(model, refList, event.values); + model._insert(refList.idsSegments, event.index, ids); return; } if (type === 'remove') { - var index = eventArgs[0]; - var howMany = eventArgs[1].length; - var ids = model._remove(refList.idsSegments, index, howMany); + const howMany = event.values.length; + const ids = model._remove(refList.idsSegments, event.index, howMany); // Delete the appropriate items underneath `to` if the `deleteRemoved` // option was set true if (refList.deleteRemoved) { @@ -61,16 +92,13 @@ function patchFromEvent(type, segments, eventArgs, refList) { } if (type === 'move') { - var from = eventArgs[0]; - var to = eventArgs[1]; - var howMany = eventArgs[2]; - model._move(refList.idsSegments, from, to, howMany); + model._move(refList.idsSegments, event.from, event.to, event.howMany); return; } // Change of the entire output var values = (type === 'change') ? - eventArgs[0] : model._get(refList.fromSegments); + event.value : model._get(refList.fromSegments); // Set ids to empty list if output is set to null if (!values) { model._set(refList.idsSegments, []); @@ -109,21 +137,6 @@ function patchFromEvent(type, segments, eventArgs, refList) { updateIdForValue(model, refList, index, value); return; } - // The same goes for string mutations, since strings are immutable - if (type === 'stringInsert') { - var stringIndex = eventArgs[0]; - var stringValue = eventArgs[1]; - model._stringInsert(toSegments, stringIndex, stringValue); - updateIdForValue(model, refList, index, value); - return; - } - if (type === 'stringRemove') { - var stringIndex = eventArgs[0]; - var howMany = eventArgs[1]; - model._stringRemove(toSegments, stringIndex, howMany); - updateIdForValue(model, refList, index, value); - return; - } if (type === 'insert' || type === 'remove' || type === 'move') { throw new Error('Array mutation on child of refList `from`' + 'should have been dereferenced: ' + segments.join('.')); @@ -136,16 +149,16 @@ function patchFromEvent(type, segments, eventArgs, refList) { * @param {RefList} refList * @param {Array} values */ -function setNewToValues(model, refList, values, fn) { +function setNewToValues(model, refList, values) { var ids = []; for (var i = 0; i < values.length; i++) { var value = values[i]; var id = refList.idByItem(value); - if (id === void 0 && typeof value === 'object') { + if (id === undefined && typeof value === 'object') { id = value.id = model.id(); } var toSegments = refList.toSegmentsByItem(value); - if (id === void 0 || toSegments === void 0) { + if (id === undefined || toSegments === undefined) { throw new Error('Unable to add item to refList: ' + value); } if (model._get(toSegments) !== value) { @@ -161,17 +174,16 @@ function updateIdForValue(model, refList, index, value) { model._set(outSegments, id); } -function patchToEvent(type, segments, eventArgs, refList) { +function patchToEvent(segments, event, refList) { + var type = event.type; var toLength = refList.toSegments.length; var segmentsLength = segments.length; - var pass = eventArgs[eventArgs.length - 1]; - var model = refList.model.pass(pass, true); + var model = refList.model.pass(event.passed, true); // Mutation on the `to` object itself if (segmentsLength === toLength) { if (type === 'insert') { - var insertIndex = eventArgs[0]; - var values = eventArgs[1]; + var values = event.values; for (var i = 0; i < values.length; i++) { var value = values[i]; var indices = refList.indicesByItem(value); @@ -185,15 +197,15 @@ function patchToEvent(type, segments, eventArgs, refList) { } if (type === 'remove') { - var removeIndex = eventArgs[0]; - var values = eventArgs[1]; + var removeIndex = event.index as number; + var values = event.values; var howMany = values.length; for (var i = removeIndex, len = removeIndex + howMany; i < len; i++) { var indices = refList.indicesByItem(values[i]); if (!indices) continue; for (var j = 0, indicesLen = indices.length; j < indicesLen; j++) { var outSegments = refList.fromSegments.concat(indices[j]); - model._set(outSegments, void 0); + model._set(outSegments, undefined); } } return; @@ -220,13 +232,13 @@ function patchToEvent(type, segments, eventArgs, refList) { var indices = refList.indicesByItem(value); if (!indices) return; var remaining = segments.slice(toLength + 1); + var eventClone = event.clone(); + eventClone.passed = model._pass; for (var i = 0; i < indices.length; i++) { var index = indices[i]; var dereferenced = refList.fromSegments.concat(index, remaining); dereferenced = model._dereference(dereferenced, null, refList); - eventArgs = eventArgs.slice(); - eventArgs[eventArgs.length - 1] = model._pass; - model.emit(type, dereferenced, eventArgs); + model._emitMutation(dereferenced, eventClone); } return; } @@ -236,17 +248,8 @@ function patchToEvent(type, segments, eventArgs, refList) { // If changing the item itself, it will also have to be re-set on the // array created by the refList if (type === 'change' || type === 'load' || type === 'unload') { - var value, previous; - if (type === 'change') { - value = eventArgs[0]; - previous = eventArgs[1]; - } else if (type === 'load') { - value = eventArgs[0]; - previous = void 0; - } else if (type === 'unload') { - value = void 0; - previous = eventArgs[0]; - } + var value = event.value; + var previous = event.previous; var newIndices = refList.indicesByItem(value); var oldIndices = refList.indicesByItem(previous); if (!newIndices && !oldIndices) return; @@ -254,7 +257,7 @@ function patchToEvent(type, segments, eventArgs, refList) { // The changed item used to refer to some indices, but no longer does for (var i = 0; i < oldIndices.length; i++) { var outSegments = refList.fromSegments.concat(oldIndices[i]); - model._set(outSegments, void 0); + model._set(outSegments, undefined); } } if (newIndices) { @@ -270,34 +273,15 @@ function patchToEvent(type, segments, eventArgs, refList) { var indices = refList.indicesByItem(value); if (!indices) return; - // The same goes for string mutations, since strings are immutable - if (type === 'stringInsert') { - var stringIndex = eventArgs[0]; - var value = eventArgs[1]; - for (var i = 0; i < indices.length; i++) { - var outSegments = refList.fromSegments(indices[i]); - model._stringInsert(outSegments, stringIndex, value); - } - return; - } - if (type === 'stringRemove') { - var stringIndex = eventArgs[0]; - var howMany = eventArgs[1]; - for (var i = 0; i < indices.length; i++) { - var outSegments = refList.fromSegments(indices[i]); - model._stringRemove(outSegments, stringIndex, howMany); - } - return; - } if (type === 'insert' || type === 'remove' || type === 'move') { // Array mutations will have already been updated via an object // reference, so only re-emit + var eventClone = event.clone(); + eventClone.passed = model._pass; for (var i = 0; i < indices.length; i++) { var dereferenced = refList.fromSegments.concat(indices[i]); dereferenced = model._dereference(dereferenced, null, refList); - eventArgs = eventArgs.slice(); - eventArgs[eventArgs.length - 1] = model._pass; - model.emit(type, dereferenced, eventArgs); + model._emitMutation(dereferenced, eventClone); } } } @@ -310,39 +294,34 @@ function equivalentArrays(a, b) { return true; } -function patchIdsEvent(type, segments, eventArgs, refList) { +function patchIdsEvent(segments, event, refList) { + var type = event.type; var idsLength = refList.idsSegments.length; var segmentsLength = segments.length; - var pass = eventArgs[eventArgs.length - 1]; - var model = refList.model.pass(pass, true); + var model = refList.model.pass(event.passed, true); // An array mutation of the ids should be mirrored with a like change in // the output array if (segmentsLength === idsLength) { if (type === 'insert') { - var index = eventArgs[0]; - var inserted = eventArgs[1]; + var inserted = event.values; var values = []; for (var i = 0; i < inserted.length; i++) { var value = refList.itemById(inserted[i]); values.push(value); } - model._insert(refList.fromSegments, index, values); + model._insert(refList.fromSegments, event.index, values); return; } if (type === 'remove') { - var index = eventArgs[0]; - var howMany = eventArgs[1].length; - model._remove(refList.fromSegments, index, howMany); + var howMany = event.values.length; + model._remove(refList.fromSegments, event.index, howMany); return; } if (type === 'move') { - var from = eventArgs[0]; - var to = eventArgs[1]; - var howMany = eventArgs[2]; - model._move(refList.fromSegments, from, to, howMany); + model._move(refList.fromSegments, event.from, event.to, event.howMany); return; } } @@ -399,122 +378,150 @@ Model.prototype.refList = function() { toPath = this.path(to); } var idsPath = this.path(ids); - var refList = this.root._refLists.add(fromPath, toPath, idsPath, options); - this.pass({$refList: refList})._setArrayDiff(refList.fromSegments, refList.get()); + var refList = new RefList(this.root, fromPath, toPath, idsPath, options); + this.root._refLists.remove(refList.fromSegments); + refList.model._setArrayDiff(refList.fromSegments, refList.get()); + this.root._refLists.add(refList); return this.scope(fromPath); }; -function RefList(model, from, to, ids, options) { - this.model = model && model.pass({$refList: this}); - this.from = from; - this.to = to; - this.ids = ids; - this.fromSegments = from && from.split('.'); - this.toSegments = to && to.split('.'); - this.idsSegments = ids && ids.split('.'); - this.options = options; - this.deleteRemoved = options && options.deleteRemoved; -} - -// The default implementation assumes that the ids array is a flat list of -// keys on the to object. Ideally, this mapping could be customized via -// inheriting from RefList and overriding these methods without having to -// modify the above event handling code. -// -// In the default refList implementation, `key` and `id` are equal. -// -// Terms in the below methods: -// `item` - Object on the `to` path, which gets mirrored on the `from` path -// `key` - The property under `to` at which an item is located -// `id` - String or object in the array at the `ids` path -// `index` - The index of an id, which corresponds to an index on `from` -RefList.prototype.get = function() { - var ids = this.model._get(this.idsSegments); - if (!ids) return []; - var items = this.model._get(this.toSegments); - var out = []; - for (var i = 0; i < ids.length; i++) { - var key = ids[i]; - out.push(items && items[key]); - } - return out; -}; -RefList.prototype.dereference = function(segments, i) { - var remaining = segments.slice(i + 1); - var key = this.idByIndex(remaining[0]); - if (key == null) return []; - remaining[0] = key; - return this.toSegments.concat(remaining); -}; -RefList.prototype.toSegmentsByItem = function(item) { - var key = this.idByItem(item); - if (key === void 0) return; - return this.toSegments.concat(key); -}; -RefList.prototype.idByItem = function(item) { - if (item && item.id) return item.id; - var items = this.model._get(this.toSegments); - for (var key in items) { - if (item === items[key]) return key; +export class RefList{ + model: Model; + from: any; + to: any; + ids: string[]; + fromSegments: any; + toSegments: any; + idsSegments: any; + options?: RefListOptions; + deleteRemoved: boolean; + + constructor(model: Model, from, to, ids, options?: RefListOptions) { + this.model = model && model.pass({$refList: this}); + this.from = from; + this.to = to; + this.ids = ids; + this.fromSegments = from && from.split('.'); + this.toSegments = to && to.split('.'); + this.idsSegments = ids && ids.split('.'); + this.options = options; + this.deleteRemoved = options && options.deleteRemoved; } -}; -RefList.prototype.indicesByItem = function(item) { - var id = this.idByItem(item); - var ids = this.model._get(this.idsSegments); - if (!ids) return; - var indices; - var index = -1; - while (true) { - index = ids.indexOf(id, index + 1); - if (index === -1) break; - if (indices) { - indices.push(index); - } else { - indices = [index]; + + // The default implementation assumes that the ids array is a flat list of + // keys on the to object. Ideally, this mapping could be customized via + // inheriting from RefList and overriding these methods without having to + // modify the above event handling code. + // + // In the default refList implementation, `key` and `id` are equal. + // + // Terms in the below methods: + // `item` - Object on the `to` path, which gets mirrored on the `from` path + // `key` - The property under `to` at which an item is located + // `id` - String or object in the array at the `ids` path + // `index` - The index of an id, which corresponds to an index on `from` + get() { + var ids = this.model._get(this.idsSegments); + if (!ids) return []; + var items = this.model._get(this.toSegments); + var out = []; + for (var i = 0; i < ids.length; i++) { + var key = ids[i]; + out.push(items && items[key]); } - } - return indices; -}; -RefList.prototype.itemById = function(id) { - return this.model._get(this.toSegments.concat(id)); -}; -RefList.prototype.idByIndex = function(index) { - return this.model._get(this.idsSegments.concat(index)); -}; -RefList.prototype.onMutation = function(type, segments, eventArgs) { - if (util.mayImpact(this.toSegments, segments)) { - patchToEvent(type, segments, eventArgs, this); - } else if (util.mayImpact(this.idsSegments, segments)) { - patchIdsEvent(type, segments, eventArgs, this); - } else if (util.mayImpact(this.fromSegments, segments)) { - patchFromEvent(type, segments, eventArgs, this); - } -}; + return out; + }; + + dereference(segments, i) { + var remaining = segments.slice(i + 1); + var key = this.idByIndex(remaining[0]); + if (key == null) return []; + remaining[0] = key; + return this.toSegments.concat(remaining); + }; + + toSegmentsByItem(item) { + var key = this.idByItem(item); + if (key === undefined) return; + return this.toSegments.concat(key); + }; + + idByItem(item) { + if (item && item.id) return item.id; + var items = this.model._get(this.toSegments); + for (var key in items) { + if (item === items[key]) return key; + } + }; + + indicesByItem(item) { + var id = this.idByItem(item); + var ids = this.model._get(this.idsSegments); + if (!ids) return; + var indices; + var index = -1; + for (;;) { + index = ids.indexOf(id, index + 1); + if (index === -1) break; + if (indices) { + indices.push(index); + } else { + indices = [index]; + } + } + return indices; + }; -function FromMap() {} + itemById(id: string) { + return this.model._get(this.toSegments.concat(id)); + }; -function RefLists(model) { - this.model = model; - this.fromMap = new FromMap(); + idByIndex(index: number) { + return this.model._get(this.idsSegments.concat(index)); + }; } -RefLists.prototype.add = function(from, to, ids, options) { - var refList = new RefList(this.model, from, to, ids, options); - this.fromMap[from] = refList; - return refList; -}; - -RefLists.prototype.remove = function(from) { - var refList = this.fromMap[from]; - delete this.fromMap[from]; - return refList; -}; +export class RefLists{ + fromMap: EventMapTree; + toListeners: EventListenerTree; + idsListeners: EventListenerTree; -RefLists.prototype.toJSON = function() { - var out = []; - for (var from in this.fromMap) { - var refList = this.fromMap[from]; - out.push([refList.from, refList.to, refList.ids, refList.options]); + constructor() { + this.fromMap = new EventMapTree(); + var toListeners = this.toListeners = new EventListenerTree(); + var idsListeners = this.idsListeners = new EventListenerTree(); } - return out; -}; + + add(refList) { + this.fromMap.setListener(refList.fromSegments, refList); + this.toListeners.addListener(refList.toSegments, refList); + this.idsListeners.addListener(refList.idsSegments, refList); + }; + + remove(fromSegments) { + var refList = this.fromMap.deleteListener(fromSegments); + if (!refList) return; + this.toListeners.removeListener(refList.toSegments, refList); + this.idsListeners.removeListener(refList.idsSegments, refList); + }; + + removeAll(segments) { + var node = this.fromMap.deleteAllListeners(segments); + if (node) { + node.forEach(node => this._removeInputListeners(node)); + } + }; + + toJSON() { + var out = []; + this.fromMap.forEach(function(refList) { + out.push([refList.from, refList.to, refList.ids, refList.options]); + }); + return out; + }; + + _removeInputListeners(refList) { + this.toListeners.removeListener(refList.toSegments, refList); + this.idsListeners.removeListener(refList.idsSegments, refList); + }; +} diff --git a/src/Model/setDiff.ts b/src/Model/setDiff.ts new file mode 100644 index 000000000..83fd33f6b --- /dev/null +++ b/src/Model/setDiff.ts @@ -0,0 +1,272 @@ +import * as util from '../util'; +import { Callback, Path, ReadonlyDeep } from '../types'; +import { Model } from './Model'; +import { type Segments } from '../types'; + +var arrayDiff = require('arraydiff'); +var mutationEvents = require('./events').mutationEvents; +var ChangeEvent = mutationEvents.ChangeEvent; +var InsertEvent = mutationEvents.InsertEvent; +var RemoveEvent = mutationEvents.RemoveEvent; +var MoveEvent = mutationEvents.MoveEvent; +var promisify = util.promisify; + +declare module './Model' { + interface Model { + /** + * Sets the value at this model's path or a relative subpath, if different + * from the current value based on a strict equality comparison (`===`). + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @param subpath + * @param value + * @returns the value previously at the path + */ + setDiff(subpath: Path, value: S, cb?: Callback): ReadonlyDeep | undefined; + setDiff(value: T | undefined): ReadonlyDeep | undefined; + setDiffPromised(subpath: Path, value: any): Promise; + _setDiff(segments: Segments, value: any, cb?: (err: Error) => void): void; + + /** + * Sets the value at this model's path or a relative subpath, if different + * from the current value based on a recursive deep equal comparison. + * + * This attempts to issue fine-grained ops on subpaths if possible. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @param subpath + * @param value + * @returns the value previously at the path + */ + setDiffDeep(subpath: Path, value: S, cb?: Callback): ReadonlyDeep | undefined; + setDiffDeep(value: T): ReadonlyDeep | undefined; + setDiffDeepPromised(subpath: Path, value: S): Promise; + _setDiffDeep(segments: Segments, value: any, cb?: (err: Error) => void): void; + + /** + * Sets the array value at this model's path or a relative subpath, based on + * a strict equality comparison (`===`) between array items. + * + * This only issues array insert, remove, and move operations. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @param subpath + * @param value + * @returns the value previously at the path + */ + setArrayDiff(subpath: Path, value: S, cb?: Callback): S; + setArrayDiff(value: S): S; + setArrayDiffPromised(subpath: Path, value: S): Promise; + _setArrayDiff(segments: Segments, value: any, cb?: (err: Error) => void, equalFn?: any): void; + + /** + * Sets the array value at this model's path or a relative subpath, based on + * a deep equality comparison between array items. + * + * This only issues array insert, remove, and move operations. Unlike + * `setDiffDeep`, this will never issue fine-grained ops inside of array + * items. + * + * If a callback is provided, it's called when the write is committed or + * fails. + * + * @param subpath + * @param value + * @returns the value previously at the path + */ + setArrayDiffDeep(subpath: Path, value: S, cb?: Callback): S; + setArrayDiffDeep(value: S): S; + setArrayDiffDeepPromised(subpath: Path, value: S): Promise; + _setArrayDiffDeep(segments: Segments, value: any, cb?: (err: Error) => void): void; + + _setArrayDiff(segments: Segments, value: any, cb?: (err: Error) => void, equalFn?: any): S; + _applyArrayDiff(segments: Segments, diff: any, cb?: (err: Error) => void): S; + } +} + +Model.prototype.setDiff = function() { + var subpath, value, cb; + if (arguments.length === 1) { + value = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + value = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._setDiff(segments, value, cb); +}; +Model.prototype.setDiffPromised = promisify(Model.prototype.setDiff); + +Model.prototype._setDiff = function(segments, value, cb) { + segments = this._dereference(segments); + var model = this; + function setDiff(doc, docSegments, fnCb) { + var previous = doc.get(docSegments); + if (util.equal(previous, value)) { + fnCb(); + return previous; + } + doc.set(docSegments, value, fnCb); + var event = new ChangeEvent(value, previous, model._pass); + model._emitMutation(segments, event); + return previous; + } + return this._mutate(segments, setDiff, cb); +}; + +Model.prototype.setDiffDeep = function() { + var subpath, value, cb; + if (arguments.length === 1) { + value = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + value = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._setDiffDeep(segments, value, cb); +}; +Model.prototype.setDiffDeepPromised = promisify(Model.prototype.setDiffDeep); + +Model.prototype._setDiffDeep = function(segments, value, cb) { + var before = this._get(segments); + cb = this.wrapCallback(cb); + var group = util.asyncGroup(cb); + var finished = group(); + diffDeep(this, segments, before, value, group); + finished(); +}; + +function diffDeep(model, segments, before, after, group) { + if (typeof before !== 'object' || !before || + typeof after !== 'object' || !after) { + // Diff the entire value if not diffable objects + model._setDiff(segments, after, group()); + return; + } + if (Array.isArray(before) && Array.isArray(after)) { + var diff = arrayDiff(before, after, util.deepEqual); + if (!diff.length) return; + // If the only change is a single item replacement, diff the item instead + if ( + diff.length === 2 && + diff[0].index === diff[1].index && + diff[0] instanceof arrayDiff.RemoveDiff && + diff[0].howMany === 1 && + diff[1] instanceof arrayDiff.InsertDiff && + diff[1].values.length === 1 + ) { + var index = diff[0].index; + var itemSegments = segments.concat(index); + diffDeep(model, itemSegments, before[index], after[index], group); + return; + } + model._applyArrayDiff(segments, diff, group()); + return; + } + + // Delete keys that were in before but not after + for (var key in before) { + if (key in after) continue; + var itemSegments = segments.concat(key); + model._del(itemSegments, group()); + } + + // Diff each property in after + for (var key in after) { + if (util.deepEqual(before[key], after[key])) continue; + var itemSegments = segments.concat(key); + diffDeep(model, itemSegments, before[key], after[key], group); + } +} + +Model.prototype.setArrayDiff = function() { + var subpath, value, cb; + if (arguments.length === 1) { + value = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + value = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._setArrayDiff(segments, value, cb); +}; +Model.prototype.setArrayDiffPromised = promisify(Model.prototype.setArrayDiff); + +Model.prototype.setArrayDiffDeep = function() { + var subpath, value, cb; + if (arguments.length === 1) { + value = arguments[0]; + } else if (arguments.length === 2) { + subpath = arguments[0]; + value = arguments[1]; + } else { + subpath = arguments[0]; + value = arguments[1]; + cb = arguments[2]; + } + var segments = this._splitPath(subpath); + return this._setArrayDiffDeep(segments, value, cb); +}; +Model.prototype.setArrayDiffDeepPromised = promisify(Model.prototype.setArrayDiffDeep); + +Model.prototype._setArrayDiffDeep = function(segments, value, cb) { + return this._setArrayDiff(segments, value, cb, util.deepEqual); +}; + +Model.prototype._setArrayDiff = function(segments, value, cb, _equalFn) { + var before = this._get(segments); + if (before === value) return this.wrapCallback(cb)(); + if (!Array.isArray(before) || !Array.isArray(value)) { + this._set(segments, value, cb); + return; + } + var diff = arrayDiff(before, value, _equalFn); + this._applyArrayDiff(segments, diff, cb); +}; + +Model.prototype._applyArrayDiff = function(segments, diff, cb) { + if (!diff.length) return this.wrapCallback(cb)(); + segments = this._dereference(segments); + var model = this; + function applyArrayDiff(doc, docSegments, fnCb) { + var group = util.asyncGroup(fnCb); + for (var i = 0, len = diff.length; i < len; i++) { + var item = diff[i]; + if (item instanceof arrayDiff.InsertDiff) { + // Insert + doc.insert(docSegments, item.index, item.values, group()); + var event = new InsertEvent(item.index, item.values, model._pass); + model._emitMutation(segments, event); + } else if (item instanceof arrayDiff.RemoveDiff) { + // Remove + var removed = doc.remove(docSegments, item.index, item.howMany, group()); + var event = new RemoveEvent(item.index, removed, model._pass); + model._emitMutation(segments, event); + } else if (item instanceof arrayDiff.MoveDiff) { + // Move + var moved = doc.move(docSegments, item.from, item.to, item.howMany, group()); + var event = new MoveEvent(item.from, item.to, moved.length, model._pass); + model._emitMutation(segments, event); + } + } + } + return this._mutate(segments, applyArrayDiff, cb); +}; diff --git a/src/Model/subscriptions.ts b/src/Model/subscriptions.ts new file mode 100644 index 000000000..420d6144e --- /dev/null +++ b/src/Model/subscriptions.ts @@ -0,0 +1,507 @@ +import { Model } from './Model'; +import { CollectionCounter } from './CollectionCounter'; +import { mutationEvents } from './events'; +import { Query } from './Query'; +import * as util from '../util'; +const UnloadEvent = mutationEvents.UnloadEvent; +const promisify = util.promisify; + +/** + * A path string, a {@link Model}, or a {@link Query}. + */ +export type Subscribable = string | Model | Query; + +declare module './Model' { + interface Model { + /** + * Retrieve data from the server, loading it into the model. + * + * @param items - Items to fetch + * @param cb - Callback called when operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + fetch(items: Subscribable[], cb?: ErrorCallback): Model; + /** + * Retrieve data from the server, loading it into the model. + * + * @param item - Item to fetch + * @param cb - Callback called when operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + fetch(item: Subscribable, cb?: ErrorCallback): Model; + /** + * Retrieve data from the server, loading it into the model. + * + * @param cb - Callback called when operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + fetch(cb?: ErrorCallback): Model; + + /** + * Promised version of {@link Model.fetch}. Instead of a callback, returns a promise + * that is resolved when operation completed + * + * @param items + */ + fetchPromised(items: Subscribable[]): Promise; + /** + * Promised version of {@link Model.fetch}. Instead of a callback, returns a promise + * that is resolved when operation completed + * + * @param item + */ + fetchPromised(item: Subscribable): Promise; + /** + * Promised version of {@link Model.fetch}. Instead of a callback, returns a promise + * that is resolved when operation completed + */ + fetchPromised(): Promise; + + /** + * Retrieve data from the server, loading it into the model. + * + * @param collecitonName - Name of colleciton to load item to + * @param id - Id of doc to load + * @param callback - Callback called when operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + fetchDoc(collecitonName: string, id: string, callback?: ErrorCallback): void; + /** + * Promised version of {@link Model.fetchDoc}. Instead of a callback, returns a promise + * that is resolved when operation completed + * + * @param collecitonName - Name of colleciton to load item to + * @param id - Id of doc to load + */ + fetchDocPromised(collecitonName: string, id: string): Promise; + + fetchOnly: boolean; + + /** + * Retrieve data from the server, loading it into the model. In addition, + * subscribe to the items, such that updates from any other client will + * automatically get reflected in this client's model. + * + * Any item that's already subscribed will not result in a network call. + * + * @param items - Item to subscribe to + * @param cb - Callback called when operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + subscribe(items: Subscribable[], cb?: ErrorCallback): Model; + /** + * Retrieve data from the server, loading it into the model. In addition, + * subscribe to the items, such that updates from any other client will + * automatically get reflected in this client's model. + * + * Any item that's already subscribed will not result in a network call. + * + * @param item - Item to subscribe to + * @param cb - Callback called when operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + subscribe(item: Subscribable, cb?: ErrorCallback): Model; + /** + * Retrieve data from the server, loading it into the model. In addition, + * subscribe to the items, such that updates from any other client will + * automatically get reflected in this client's model. + * + * Any item that's already subscribed will not result in a network call. + * + * @param cb - Callback called when operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + subscribe(cb?: ErrorCallback): Model; + /** + * Promised version of {@link Model.subscribe}. Instead of a callback, returns a promise + * that is resolved when operation completed + * + * @param items - Items to subscribe to + */ + subscribePromised(items: Subscribable[]): Promise; + /** + * Promised version of {@link Model.subscribe}. Instead of a callback, returns a promise + * that is resolved when operation completed + * + * @param item - Item to subscribe to + */ + subscribePromised(item: Subscribable): Promise; + /** + * Promised version of {@link Model.subscribe}. Instead of a callback, returns a promise + * that is resolved when operation completed + */ + subscribePromised(): Promise; + + subscribeDoc(collecitonName: string, id: string, callback?: ErrorCallback): void; + subscribeDocPromised(collecitonName: string, id: string): Promise; + + /** + * The reverse of {@link Model.fetch}, marking the items as no longer needed in the + * model. + * + * @param items - Items to unfetch + * @param cb - Optional Called after operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + unfetch(items: Subscribable[], cb?: ErrorCallback): Model; + /** + * The reverse of {@link Model.fetch}, marking the items as no longer needed in the + * model. + * + * @param item - Item to unfetch + * @param cb - Optional Called after operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + unfetch(item: Subscribable, cb?: ErrorCallback): Model; + /** + * The reverse of {@link Model.fetch}, marking the items as no longer needed in the + * model. + * + * @param cb - Optional Called after operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + unfetch(cb?: ErrorCallback): Model; + + /** + * Promised unfetch. See {@link Model.unfetch}. Instead of a callback, returns a promise + * that is resolved when operation completed + * + * @param items - Items to unfetch + * @returns Promise + */ + unfetchPromised(items: Subscribable[]): Promise; + /** + * Promised unfetch. See {@link Model.unfetch}. Instead of a callback, returns a promise + * that is resolved when operation completed + * + * @param item - Item to unfetch + * @returns Promise + */ + unfetchPromised(item: Subscribable): Promise; + /** + * Promised {@link Model.unfetch}. Instead of taking a callback, returns a promise + * that is resolved when operation completed + * + * @returns Promise + */ + unfetchPromised(): Promise; + + /** + * Unfetch a document give collection name and document id + * + * @param collectionName - Collection name + * @param id - Document id to be unfeched + */ + unfetchDoc(collectionName: string, id: string, callback?: (err?: Error, count?: number) => void): void; + /** + * Promised {@link Model.unfetchDoc}. Instead of taking a callback, returns a promise + * that is resolved when operation completed + * + * @param collectionName - Collection name + * @param id - Document id to be unfeched + * @returns Promise + */ + unfetchDocPromised(collectionName: string, id: string): Promise; + /** + * Delay in milliseconds before model data actually unloaded after call to {@link Model.unload} + */ + unloadDelay: number; + + /** + * The reverse of {@link Model.subscribe}, marking the items as no longer needed in the + * model. + * + * @param items - The items to unsubscribe + * @param cb - Optional Called after operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + unsubscribe(items: Subscribable[], cb?: ErrorCallback): Model; + /** + * The reverse of {@link Model.subscribe}, marking the items as no longer needed in the + * model. + * + * @param item - The item to unsubscribe + * @param cb - Optional Called after operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + unsubscribe(item: Subscribable, cb?: ErrorCallback): Model; + /** + * The reverse of {@link Model.subscribe}, marking the items as no longer needed in the + * model. + * + * @param cb - Optional Called after operation completed + * + * @see https://derbyjs.github.io/derby/models/backends#loading-data-into-a-model + */ + unsubscribe(cb?: ErrorCallback): Model; + + /** + * Promised version of {@link Model.unsubscribe}. Instead of taking a callback, returns a promise + * that is resolved when operation completed + */ + unsubscribePromised(): Promise; + + /** + * Unsubscribe document by collection name and id + * + * @param collectionName - Name of collection containting document + * @param id - Document id to unsubscribe + * @param callback - Optional Called after operation completed + */ + unsubscribeDoc(collectionName: string, id: string, callback?: (err?: Error, count?: number) => void): void; + /** + * Promised version of {@link Model.unsubscribeDoc} + * + * @param collectionName - Name of collection containting document + * @param id - Document id to unsbscribe + */ + unsubscribeDocPromised(collectionName: string, id: string): Promise; + + _fetchedDocs: CollectionCounter; + _forSubscribable(argumentsObject: any, method: any): void; + _hasDocReferences(collecitonName: string, id: string): boolean; + _maybeUnloadDoc(collecitonName: string, id: string): void; + _subscribedDocs: CollectionCounter; + } +} + +Model.INITS.push(function(model, options) { + model.root.fetchOnly = options.fetchOnly; + model.root.unloadDelay = options.unloadDelay || (util.isServer) ? 0 : 1000; + + // Track the total number of active fetches per doc + model.root._fetchedDocs = new CollectionCounter(); + // Track the total number of active susbscribes per doc + model.root._subscribedDocs = new CollectionCounter(); +}); + +Model.prototype.fetch = function() { + this._forSubscribable(arguments, 'fetch'); + return this; +}; +Model.prototype.fetchPromised = promisify(Model.prototype.fetch); + +Model.prototype.unfetch = function() { + this._forSubscribable(arguments, 'unfetch'); + return this; +}; +Model.prototype.unfetchPromised = promisify(Model.prototype.unfetch); + +Model.prototype.subscribe = function() { + this._forSubscribable(arguments, 'subscribe'); + return this; +}; +Model.prototype.subscribePromised = promisify(Model.prototype.subscribe); + +Model.prototype.unsubscribe = function() { + this._forSubscribable(arguments, 'unsubscribe'); + return this; +}; +Model.prototype.unsubscribePromised = promisify(Model.prototype.unsubscribe); + +Model.prototype._forSubscribable = function(argumentsObject, method) { + var args, cb; + if (!argumentsObject.length) { + // Use this model's scope if no arguments + args = [null]; + } else if (typeof argumentsObject[0] === 'function') { + // Use this model's scope if the first argument is a callback + args = [null]; + cb = argumentsObject[0]; + } else if (Array.isArray(argumentsObject[0])) { + // Items can be passed in as an array + args = argumentsObject[0]; + cb = argumentsObject[1]; + } else { + // Or as multiple arguments + args = Array.prototype.slice.call(argumentsObject); + var last = args[args.length - 1]; + if (typeof last === 'function') cb = args.pop(); + } + + var group = util.asyncGroup(this.wrapCallback(cb)); + var finished = group(); + var docMethod = method + 'Doc'; + + this.root.connection.startBulk(); + for (var i = 0; i < args.length; i++) { + var item = args[i]; + if (item instanceof Query) { + item[method](group()); + } else { + var segments = this._dereference(this._splitPath(item)); + if (segments.length === 2) { + // Do the appropriate method for a single document. + this[docMethod](segments[0], segments[1], group()); + } else { + var message = 'Cannot ' + method + ' to path: ' + segments.join('.'); + group()(new Error(message)); + } + } + } + this.root.connection.endBulk(); + process.nextTick(finished); +}; + +Model.prototype.fetchDoc = function(collectionName, id, cb) { + cb = this.wrapCallback(cb); + + // Maintain a count of fetches so that we can unload the document + // when there are no remaining fetches or subscribes for that document + this._context.fetchDoc(collectionName, id); + this.root._fetchedDocs.increment(collectionName, id); + + // Fetch + var doc = this.getOrCreateDoc(collectionName, id); + doc.shareDoc.fetch(cb); +}; +Model.prototype.fetchDocPromised = promisify(Model.prototype.fetchDoc); + +Model.prototype.subscribeDoc = function(collectionName, id, cb) { + cb = this.wrapCallback(cb); + + // Maintain a count of subscribes so that we can unload the document + // when there are no remaining fetches or subscribes for that document + this._context.subscribeDoc(collectionName, id); + this.root._subscribedDocs.increment(collectionName, id); + + var doc = this.getOrCreateDoc(collectionName, id); + // Early return if we know we are already subscribed + if (doc.shareDoc.subscribed) { + return cb(); + } + // Subscribe + if (this.root.fetchOnly) { + doc.shareDoc.fetch(cb); + } else { + doc.shareDoc.subscribe(cb); + } +}; +Model.prototype.subscribeDocPromised = promisify(Model.prototype.subscribeDoc); + +Model.prototype.unfetchDoc = function(collectionName, id, cb) { + cb = this.wrapCallback(cb); + this._context.unfetchDoc(collectionName, id); + + // No effect if the document is not currently fetched + if (!this.root._fetchedDocs.get(collectionName, id)) return cb(); + + var model = this; + if (this.root.unloadDelay) { + setTimeout(finishUnfetchDoc, this.root.unloadDelay); + } else { + finishUnfetchDoc(); + } + function finishUnfetchDoc() { + var count = model.root._fetchedDocs.decrement(collectionName, id); + if (count) return cb(null, count); + model._maybeUnloadDoc(collectionName, id); + cb(null, 0); + } +}; +Model.prototype.unfetchDocPromised = promisify(Model.prototype.unfetchDoc); + +Model.prototype.unsubscribeDoc = function(collectionName, id, cb) { + cb = this.wrapCallback(cb); + this._context.unsubscribeDoc(collectionName, id); + + // No effect if the document is not currently subscribed + if (!this.root._subscribedDocs.get(collectionName, id)) return cb(); + + var model = this; + if (this.root.unloadDelay) { + setTimeout(finishUnsubscribeDoc, this.root.unloadDelay); + } else { + finishUnsubscribeDoc(); + } + function finishUnsubscribeDoc() { + var count = model.root._subscribedDocs.decrement(collectionName, id); + // If there are more remaining subscriptions, only decrement the count + // and callback with how many subscriptions are remaining + if (count) return cb(null, count); + + // If there is only one remaining subscription, actually unsubscribe + if (model.root.fetchOnly) { + unsubscribeDocCallback(); + } else { + var doc = model.getDoc(collectionName, id); + var shareDoc = doc && doc.shareDoc; + if (!shareDoc) return unsubscribeDocCallback(); + shareDoc.unsubscribe(unsubscribeDocCallback); + } + } + function unsubscribeDocCallback(err?: Error) { + model._maybeUnloadDoc(collectionName, id); + if (err) return cb(err); + cb(null, 0); + } +}; +Model.prototype.unsubscribeDocPromised = promisify(Model.prototype.unsubscribeDoc); + +// Removes the document from the local model if the model no longer has any +// remaining fetches or subscribes via a query or direct loading +Model.prototype._maybeUnloadDoc = function(collectionName, id) { + var model = this; + var doc = this.getDoc(collectionName, id); + if (!doc) return; + + // If there is a query or direct fetch or subscribe that is holding reference + // to this doc, leave it loaded + if (this._hasDocReferences(collectionName, id)) return; + // Calling sharedDoc.destroy() will remove it from the connection only when + // there aren't any operations, fetches, or subscribes pending on the doc. + // Thus, if we remove the doc from Racer's model but don't remove it from + // ShareDB, we can end up with an inconsistent state, with the data existing + // in ShareDB not reflected in the racer model data + if (doc.shareDoc && doc.shareDoc.hasPending()) { + // If the Share doc still has pending activity, retry _maybeUnloadDoc once + // the pending activity is done. + doc.shareDoc.whenNothingPending(function() { + model._maybeUnloadDoc(collectionName, id); + }); + } else { + // Otherwise, actually do the unload. + var previous = doc.get(); + + // Remove doc from Racer + if (model.root.collections[collectionName]) model.root.collections[collectionName].remove(id); + // Remove doc from Share + if (doc.shareDoc) doc.shareDoc.destroy(); + + var event = new UnloadEvent(previous, this._pass); + this._emitMutation([collectionName, id], event); + } +}; + +Model.prototype._hasDocReferences = function(collectionName, id) { + // Check if any fetched or subscribed queries currently have the + // id in their results + var queries = this.root._queries.collectionMap.getCollection(collectionName); + if (queries) { + for (var hash in queries) { + var query = queries[hash]; + if (!query.subscribeCount && !query.fetchCount) continue; + if (query.idMap[id] > 0) return true; + } + } + + // Check if document currently has direct fetch or subscribe + if ( + this.root._fetchedDocs.get(collectionName, id) || + this.root._subscribedDocs.get(collectionName, id) + ) return true; + + return false; +}; diff --git a/src/Model/unbundle.ts b/src/Model/unbundle.ts new file mode 100644 index 000000000..3ddbc7121 --- /dev/null +++ b/src/Model/unbundle.ts @@ -0,0 +1,80 @@ +import { Model } from './Model'; + +declare module './Model' { + interface Model { + unbundle: (data: any) => void; + } +} + +Model.prototype.unbundle = function(data) { + if (this.connection) this.connection.startBulk(); + + // Re-create and subscribe queries; re-create documents associated with queries + this._initQueries(data.queries); + + // Re-create other documents + for (var collectionName in data.collections) { + var collection = data.collections[collectionName]; + for (var id in collection) { + this.getOrCreateDoc(collectionName, id, collection[id]); + } + } + + for (var contextId in data.contexts) { + var contextData = data.contexts[contextId]; + var contextModel = this.context(contextId); + // Re-init fetchedDocs counts + for (var collectionName in contextData.fetchedDocs) { + var collection = contextData.fetchedDocs[collectionName]; + for (var id in collection) { + var count = collection[id]; + while (count--) { + contextModel._context.fetchDoc(collectionName, id); + this._fetchedDocs.increment(collectionName, id); + } + } + } + // Subscribe to document subscriptions + for (var collectionName in contextData.subscribedDocs) { + var collection = contextData.subscribedDocs[collectionName]; + for (var id in collection) { + var count = collection[id]; + while (count--) { + contextModel.subscribeDoc(collectionName, id); + } + } + } + // Re-init createdDocs counts + for (var collectionName in contextData.createdDocs) { + var collection = contextData.createdDocs[collectionName]; + for (var id in collection) { + // Count value doesn't matter for tracking creates + contextModel._context.createDoc(collectionName, id); + } + } + } + + if (this.connection) this.connection.endBulk(); + + // Re-create refs + for (var i = 0; i < data.refs.length; i++) { + var item = data.refs[i]; + this.ref(item[0], item[1]); + } + // Re-create refLists + for (var i = 0; i < data.refLists.length; i++) { + var item = data.refLists[i]; + this.refList(item[0], item[1], item[2], item[3]); + } + // Re-create fns + for (var i = 0; i < data.fns.length; i++) { + var item = data.fns[i]; + this.start.apply(this, item); + } + // Re-create filters + for (var i = 0; i < data.filters.length; i++) { + var item = data.filters[i]; + var filter = this._filters.add(item[1], item[2], item[3], item[4], item[5]); + filter.ref(item[0]); + } +}; diff --git a/src/Racer.server.ts b/src/Racer.server.ts new file mode 100644 index 000000000..200cb9cce --- /dev/null +++ b/src/Racer.server.ts @@ -0,0 +1,18 @@ +import { RacerBackend } from './Backend'; +import { Racer } from './Racer'; + +declare module './Racer' { + interface Racer { + Backend: typeof RacerBackend; + version: string; + createBackend: (options:any) => RacerBackend; + } +} + +Racer.prototype.Backend = RacerBackend; + +Racer.prototype.version = require('../package').version; + +Racer.prototype.createBackend = function(options) { + return new RacerBackend(this, options); +}; diff --git a/src/Racer.ts b/src/Racer.ts new file mode 100644 index 000000000..16109f18e --- /dev/null +++ b/src/Racer.ts @@ -0,0 +1,25 @@ +import { EventEmitter } from 'events'; +import { Model, RootModel } from './Model'; +import * as util from './util'; + +export class Racer extends EventEmitter { + Model = Model; + util = util; + use = util.use; + serverUse = util.serverUse; + + constructor() { + super(); + } + + createModel(data) { + var model = new RootModel(); + if (data) { + model.createConnection(data); + model.unbundle(data); + } + return model; + } +} + +util.serverRequire(module, './Racer.server'); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..8f5dd48a3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,55 @@ +import { Racer } from './Racer'; +import * as util from './util'; +import type { ShareDBOptions } from 'sharedb'; + +export { type RacerBackend, type BackendOptions } from './Backend'; +import { RootModel } from './Model'; +import { type BackendOptions } from './Backend'; + +export { Query } from './Model/Query'; +export { Model, ChildModel, ModelData, type UUID, type Subscribable, type DefualtType, type ModelOptions } from './Model'; +export { Context } from './Model/contexts'; +export { type ModelOnEventMap, type ModelEvent, ChangeEvent, InsertEvent, LoadEvent, MoveEvent, RemoveEvent, UnloadEvent } from './Model/events'; +export type { Callback, ReadonlyDeep, Path, PathLike, PathSegment, Primitive } from './types'; +export type { CollectionData } from './Model/collections'; +export * as util from './util'; + +const { use, serverUse } = util; + +export { + Racer, + RootModel, + use, + serverUse, +}; + +export const racer = new Racer(); + +/** + * Creates new RootModel + * + * @param data - Optional Data to initialize model with + * @returns RootModel + */ +export function createModel(data?) { + var model = new RootModel(); + if (data) { + model.createConnection(data); + model.unbundle(data); + } + return model; +} + +/** + * Creates racer backend. Can only be called in server process and throws error if called in browser. + * + * @param options - Optional + * @returns racer backend + */ +export function createBackend(options?: BackendOptions) { + const backendModule = util.serverRequire(module, './Backend'); + if (backendModule == null) { + throw new Error('racer.createBackend can only be called in server node process'); + } + return new backendModule.RacerBackend(racer, options); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..dc3adcee5 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,70 @@ +import { Model } from "./Model"; + +// +// Simple and utility types +// +export type UUID = string; +export type Path = string | number; +export type PathSegment = string | number; +export type PathLike = Path | Model; +export type Segments = Array; + +export type Primitive = boolean | number | string | null | undefined; + +/** If `T` is an array, produces the type of the array items. */ +export type ArrayItemType = T extends Array ? U : never; + +export type Callback = (error?: Error) => void; + +/** + * Transforms a JSON-compatible type `T` such that it and any nested arrays + * or basic objects are read-only. + * + * Warnings: + * * Instances of most classes could still be modified via methods, aside from + * built-in `Array`s, which are transformed to `ReadonlyArray`s with no + * mutator methods. + * * This only affects static type-checking and is not a guarantee of run-time + * immutability. Values with this type could still be modified if casted + * to `any`, passed into a function with an `any` signature, or by untyped + * JavaScript. + * + * Use `deepCopy(value)` to get a fully mutable copy of a `ReadonlyDeep`. + */ +export type ReadonlyDeep = T extends Primitive ? T : { readonly [K in keyof T]: ReadonlyDeep }; + +/** + * Transforms a JSON-compatible type `T` such that top-level properties remain + * mutable if they were before, but nested arrays or basic objects become + * read-only. + * + * Warning: This does not fully guarantee immutability. See `ReadonlyDeep` for + * more details. + */ +export type ShallowCopiedValue = T extends Primitive ? T : { [K in keyof T]: ReadonlyDeep }; + +/** + * Transforms the input JSON-compatible type `T` to be fully mutable, if it + * isn't already. + * + * This should only be used for the return type of a deep-copy function. Do + * not manually cast using this type otherwise. + */ +export type MutableDeep = T extends ReadonlyDeep + ? Date + : T extends object + ? { -readonly [K in keyof T]: MutableDeep } + : T; + +/** + * Transforms the input JSON-compatible type `T` such that its top-level + * properties are mutable, if they weren't already. + * + * This should only be used for the return type of a shallow-copy function. Do + * not manually cast using this type otherwise. + */ +export type MutableShallow = T extends ShallowCopiedValue + ? Date + : T extends object + ? { -readonly [K in keyof T]: T[K] } + : T; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 000000000..4d1a276f0 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,249 @@ + +/** @private */ +export const deepEqual = require('fast-deep-equal'); + +/** + * Checks process.title is not equal to 'browser' + * + * Set as 'browser' via build tools (e.g. webpack) to package + * browser specific code to bundle + * + * see {@link https://github.com/derbyjs/derby-webpack/blob/main/createConfig.js#L95 | derby-webpack} + */ +export const isServer = process.title !== 'browser'; + +/** @private */ +export function asyncGroup(cb) { + var group = new AsyncGroup(cb); + return function asyncGroupAdd() { + return group.add(); + }; +} + +type ErrorCallback = (err?: Error) => void; + +class AsyncGroup { + cb: ErrorCallback; + isDone: boolean; + count: number; + + constructor(cb: ErrorCallback) { + this.cb = cb; + this.isDone = false; + this.count = 0; + } + + add() { + this.count++; + const self = this; + return function(err?: Error) { + self.count--; + if (self.isDone) return; + if (err) { + self.isDone = true; + self.cb(err); + return; + } + if (self.count > 0) return; + self.isDone = true; + self.cb(); + }; + } +} + +/** @private */ +function castSegment(segment: string | number): string | number { + return (typeof segment === 'string' && isArrayIndex(segment)) + ? +segment // sneaky op to convert numeric string to number + : segment; +} + +/** @private */ +export function castSegments(segments: Readonly>) { + // Cast number path segments from strings to numbers + return segments.map(segment => castSegment(segment)); +} + +/** @private */ +export function contains(segments, testSegments) { + for (var i = 0; i < segments.length; i++) { + if (segments[i] !== testSegments[i]) return false; + } + return true; +} + +/** @private */ +export function copy(value) { + if (value instanceof Date) return new Date(value); + if (typeof value === 'object') { + if (value === null) return null; + if (Array.isArray(value)) return value.slice(); + return copyObject(value); + } + return value; +} + +/** @private */ +export function copyObject(object) { + var out = new object.constructor(); + for (var key in object) { + if (object.hasOwnProperty(key)) { + out[key] = object[key]; + } + } + return out; +} + +/** @private */ +export function deepCopy(value) { + if (value instanceof Date) return new Date(value); + if (typeof value === 'object') { + if (value === null) return null; + if (Array.isArray(value)) { + var array: any[] = []; + for (var i = value.length; i--;) { + array[i] = deepCopy(value[i]); + } + return array; + } + var object = new value.constructor(); + for (var key in value) { + if (value.hasOwnProperty(key)) { + object[key] = deepCopy(value[key]); + } + } + return object; + } + return value; +} + +/** @private */ +export function equal(a, b) { + return (a === b) || (equalsNaN(a) && equalsNaN(b)); +} + +/** @private */ +export function equalsNaN(x) { + // eslint-disable-next-line no-self-compare + return x !== x; +} + +/** @private */ +export function isArrayIndex(segment: string): boolean { + return (/^[0-9]+$/).test(segment); +} + +/** @private */ +export function lookup(segments: string[], value: unknown): unknown { + if (!segments) return value; + + for (var i = 0, len = segments.length; i < len; i++) { + if (value == null) return value; + value = value[segments[i]]; + } + return value; +} + +/** @private */ +export function mayImpactAny(segmentsList: string[][], testSegments: string[]) { + for (var i = 0, len = segmentsList.length; i < len; i++) { + if (mayImpact(segmentsList[i], testSegments)) return true; + } + return false; +} + +/** @private */ +export function mayImpact(segments: string[], testSegments: string[]): boolean { + var len = Math.min(segments.length, testSegments.length); + for (var i = 0; i < len; i++) { + if (segments[i] !== testSegments[i]) return false; + } + return true; +} + +/** @private */ +export function mergeInto(to, from) { + for (var key in from) { + to[key] = from[key]; + } + return to; +} + +/** @private */ +export function promisify(original) { + if (typeof original !== 'function') { + throw new TypeError('The "original" argument must be of type Function'); + } + + function fn() { + var promiseResolve, promiseReject; + var promise = new Promise(function(resolve, reject) { + promiseResolve = resolve; + promiseReject = reject; + }); + + var args = Array.prototype.slice.apply(arguments); + args.push(function(err: Error, value: T) { + if (err) { + promiseReject(err); + } else { + promiseResolve(value); + } + }); + + try { + original.apply(this, args); + } catch (err) { + promiseReject(err); + } + + return promise; + } + + return fn; +} + +/** + * Conditionally require module only if in server process. No-op when called in browser. + * + * @param module + * @param id + * @returns module or undefined + */ +export function serverRequire(module, id) { + if (!isServer) return; + return module.require(id); +} + +/** + * Use plugin only if invoked in server process. + * + * @param module + * @param id + * @param options - Optional + * @returns + */ +export function serverUse(module, id: string, options?: unknown) { + if (!isServer) return this; + var plugin = module.require(id); + return this.use(plugin, options); +} + +type Plugin = (pluginHost: T, options: O) => void; + +/** + * Use plugin + * + * @param plugin + * @param options - Optional options passed to plugin + * @returns + */ +export function use(this: T, plugin: Plugin, options?: O) { + // Don't include a plugin more than once + var plugins = (this as any)._plugins || ((this as any)._plugins = []); + if (plugins.indexOf(plugin) === -1) { + plugins.push(plugin); + plugin(this, options); + } + return this; +} diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 000000000..0c95edf69 --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = { + env: { + 'mocha': true + }, + rules: { + 'max-len': 'off', + 'max-nested-callbacks': 'off', + 'require-jsdoc': 'off' + } +}; diff --git a/test/.jshintrc b/test/.jshintrc deleted file mode 100644 index 1cb88cb41..000000000 --- a/test/.jshintrc +++ /dev/null @@ -1,22 +0,0 @@ -{ - "node": true, - "laxcomma": true, - "eqnull": true, - "eqeqeq": true, - "indent": 2, - "newcap": true, - "quotmark": "single", - "undef": true, - "trailing": true, - "shadow": true, - "expr": true, - "boss": true, - "globals": { - "describe": false, - "it": false, - "before": false, - "after": false, - "beforeEach": false, - "afterEach": false - } -} diff --git a/test/Model/CollectionCounter.js b/test/Model/CollectionCounter.js new file mode 100644 index 000000000..76e575e9e --- /dev/null +++ b/test/Model/CollectionCounter.js @@ -0,0 +1,88 @@ +var expect = require('../util').expect; +var {CollectionCounter} = require('../../lib/Model/CollectionCounter'); + +describe('CollectionCounter', function() { + describe('increment', function() { + it('increments count for a document', function() { + var counter = new CollectionCounter(); + expect(counter.get('colors', 'green')).to.equal(0); + expect(counter.increment('colors', 'green')).to.equal(1); + expect(counter.increment('colors', 'green')).to.equal(2); + expect(counter.get('colors', 'green')).to.equal(2); + }); + }); + describe('decrement', function() { + it('has no effect on empty collection', function() { + var counter = new CollectionCounter(); + expect(counter.decrement('colors', 'green')).to.equal(0); + }); + it('has no effect on empty doc in existing collection', function() { + var counter = new CollectionCounter(); + expect(counter.increment('colors', 'red')); + expect(counter.decrement('colors', 'green')).to.equal(0); + }); + it('decrements count for a document', function() { + var counter = new CollectionCounter(); + expect(counter.increment('colors', 'green')); + expect(counter.increment('colors', 'green')); + expect(counter.decrement('colors', 'green')).to.equal(1); + expect(counter.decrement('colors', 'green')).to.equal(0); + expect(counter.get('colors', 'green')).to.equal(0); + }); + it('does not affect peer document', function() { + var counter = new CollectionCounter(); + expect(counter.increment('colors', 'green')); + expect(counter.increment('colors', 'red')); + expect(counter.decrement('colors', 'green')); + expect(counter.get('colors', 'green')).to.equal(0); + expect(counter.get('colors', 'red')).to.equal(1); + }); + it('does not affect peer collection', function() { + var counter = new CollectionCounter(); + expect(counter.increment('colors', 'green')); + expect(counter.increment('textures', 'smooth')); + expect(counter.decrement('colors', 'green')); + expect(counter.get('colors', 'green')).to.equal(0); + expect(counter.get('textures', 'smooth')).to.equal(1); + }); + }); + describe('toJSON', function() { + it('returns undefined if there are no counts', function() { + var counter = new CollectionCounter(); + expect(counter.toJSON()).to.equal(undefined); + }); + it('returns a nested map representing counts', function() { + var counter = new CollectionCounter(); + counter.increment('colors', 'green'); + counter.increment('colors', 'green'); + counter.increment('colors', 'red'); + counter.increment('textures', 'smooth'); + expect(counter.toJSON()).to.eql({ + colors: { + green: 2, + red: 1 + }, + textures: { + smooth: 1 + } + }); + }); + it('incrementing then decrementing returns nothing', function() { + var counter = new CollectionCounter(); + counter.increment('colors', 'green'); + counter.decrement('colors', 'green'); + expect(counter.toJSON()).to.equal(undefined); + }); + it('decrementing id from collection with other keys removes key', function() { + var counter = new CollectionCounter(); + counter.increment('colors', 'green'); + counter.increment('colors', 'red'); + counter.decrement('colors', 'green'); + expect(counter.toJSON()).to.eql({ + colors: { + red: 1 + } + }); + }); + }); +}); diff --git a/test/Model/EventListenerTree.js b/test/Model/EventListenerTree.js new file mode 100644 index 000000000..5e1d66657 --- /dev/null +++ b/test/Model/EventListenerTree.js @@ -0,0 +1,405 @@ +var expect = require('../util').expect; +var {EventListenerTree} = require('../../lib/Model/EventListenerTree'); + +describe('EventListenerTree', function() { + describe('addListener', function() { + it('adds a listener object at the root', function() { + var tree = new EventListenerTree(); + var listener = {}; + tree.addListener([], listener); + expect(tree.getListeners([])).eql([listener]); + expect(tree.children).eql(null); + }); + it('only a listener object once', function() { + var tree = new EventListenerTree(); + var listener = {}; + tree.addListener([], listener); + tree.addListener([], listener); + expect(tree.getListeners([])).eql([listener]); + expect(tree.children).eql(null); + }); + it('adds a listener object at a path', function() { + var tree = new EventListenerTree(); + var listener = {}; + tree.addListener(['colors'], listener); + expect(tree.getListeners([])).eql([]); + expect(tree.getListeners(['colors'])).eql([listener]); + }); + it('adds a listener object at a subpath', function() { + var tree = new EventListenerTree(); + var listener = {}; + tree.addListener(['colors', 'green'], listener); + expect(tree.getListeners([])).eql([]); + expect(tree.getListeners(['colors'])).eql([]); + expect(tree.getListeners(['colors', 'green'])).eql([listener]); + }); + it('returns the node to which the listener was added', function() { + var tree = new EventListenerTree(); + var listener = {}; + var node = tree.addListener(['colors', 'green'], listener); + expect(node).instanceOf(EventListenerTree); + expect(node.parent.parent).equal(tree); + }); + }); + describe('destroy', function() { + it('can be called on empty root', function() { + var tree = new EventListenerTree(); + tree.destroy(); + expect(tree.children).eql(null); + }); + it('removes nodes up to root', function() { + var tree = new EventListenerTree(); + var node = tree.addListener(['colors', 'green'], 'listener1'); + node.destroy(); + expect(tree.children).eql(null); + }); + it('can be called on child node repeatedly', function() { + var tree = new EventListenerTree(); + var node = tree.addListener(['colors', 'green'], 'listener1'); + node.destroy(); + node.destroy(); + }); + it('does not remove parent nodes with existing listeners', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors'], 'listener1'); + var node = tree.addListener(['colors', 'green'], 'listener2'); + node.destroy(); + node.destroy(); + expect(tree.getListeners(['colors'])).eql(['listener1']); + }); + it('does not remove parent nodes with other children', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'red'], 'listener1'); + var node = tree.addListener(['colors', 'green'], 'listener2'); + node.destroy(); + node.destroy(); + expect(tree.getListeners(['colors', 'red'])).eql(['listener1']); + }); + }); + describe('removeListener', function() { + it('can be called before addListener', function() { + var tree = new EventListenerTree(); + var listener = {}; + tree.removeListener(['colors', 'green'], listener); + expect(tree.children).eql(null); + }); + it('removes listener at root', function() { + var tree = new EventListenerTree(); + var listener = {}; + tree.addListener([], listener); + expect(tree.getListeners([])).eql([listener]); + tree.removeListener([], listener); + expect(tree.getListeners([])).eql([]); + }); + it('removes listener at subpath', function() { + var tree = new EventListenerTree(); + var listener = {}; + tree.addListener(['colors', 'green'], listener); + expect(tree.getListeners(['colors', 'green'])).eql([listener]); + tree.removeListener(['colors', 'green'], listener); + expect(tree.children).eql(null); + }); + it('removes listener at subpath with remaining peers', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'green'], 'listener1'); + tree.addListener(['colors', 'red'], 'listener2'); + tree.removeListener(['colors', 'green'], 'listener1'); + expect(tree.getListeners(['colors', 'green'])).eql([]); + expect(tree.getListeners(['colors', 'red'])).eql(['listener2']); + }); + it('does not remove listener if not found with one listener', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'green'], 'listener1'); + expect(tree.getListeners(['colors', 'green'])).eql(['listener1']); + tree.removeListener(['colors', 'green'], 'listener2'); + expect(tree.getListeners(['colors', 'green'])).eql(['listener1']); + }); + it('does not remove listener if not found with multiple listeners', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'green'], 'listener1'); + tree.addListener(['colors', 'green'], 'listener2'); + expect(tree.getListeners(['colors', 'green'])).eql(['listener1', 'listener2']); + tree.removeListener(['colors', 'green'], 'listener3'); + expect(tree.getListeners(['colors', 'green'])).eql(['listener1', 'listener2']); + }); + it('removes listener with remaining peers', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener([], 'listener2'); + tree.addListener([], 'listener3'); + expect(tree.getListeners([])).eql(['listener1', 'listener2', 'listener3']); + tree.removeListener([], 'listener2'); + expect(tree.getListeners([])).eql(['listener1', 'listener3']); + tree.removeListener([], 'listener3'); + expect(tree.getListeners([])).eql(['listener1']); + tree.removeListener([], 'listener1'); + expect(tree.getListeners([])).eql([]); + }); + it('removes listener with remaining peer children', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors'], 'listener1'); + tree.addListener(['colors', 'green'], 'listener2'); + expect(tree.getListeners(['colors'])).eql(['listener1']); + expect(tree.getListeners(['colors', 'green'])).eql(['listener2']); + tree.removeListener(['colors'], 'listener1'); + expect(tree.getListeners(['colors'])).eql([]); + expect(tree.getListeners(['colors', 'green'])).eql(['listener2']); + }); + }); + describe('removeOwnListener', function() { + it('can be called on node without listeners', function() { + var tree = new EventListenerTree(); + tree.removeOwnListener('listener1'); + }); + it('removes the listener at a node', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener([], 'listener2'); + tree.removeOwnListener('listener1'); + expect(tree.getListeners([])).eql(['listener2']); + }); + it('removes listener from the node returned by addListener', function() { + var tree = new EventListenerTree(); + var node = tree.addListener(['colors', 'green'], 'listener1'); + node.removeOwnListener('listener1'); + expect(tree.getListeners(['colors', 'green'])).eql([]); + expect(tree.children).eql(null); + }); + it('can be called repeatedly', function() { + var tree = new EventListenerTree(); + var node = tree.addListener(['colors', 'green'], 'listener1'); + node.removeOwnListener('listener1'); + node.removeOwnListener('listener1'); + }); + }); + describe('removeAllListeners', function() { + it('can be called on empty root', function() { + var tree = new EventListenerTree(); + tree.removeAllListeners([]); + }); + it('can be called on missing node', function() { + var tree = new EventListenerTree(); + tree.removeAllListeners(['colors', 'green']); + }); + it('removes all listeners and children when called on root', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener(['colors'], 'listener2'); + tree.addListener(['colors', 'green'], 'listener3'); + tree.removeAllListeners([]); + expect(tree.getListeners([])).eql([]); + expect(tree.children).eql(null); + }); + it('removes listeners and descendent children on path', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener(['colors'], 'listener2'); + tree.addListener(['colors', 'green'], 'listener3'); + tree.removeAllListeners(['colors']); + expect(tree.getListeners([])).eql(['listener1']); + expect(tree.children).eql(null); + }); + }); + describe('getAffectedListeners', function() { + it('returns empty array without listeners', function() { + var tree = new EventListenerTree(); + var affected = tree.getAffectedListeners([]); + expect(affected).eql([]); + }); + it('returns empty array on path without node', function() { + var tree = new EventListenerTree(); + var affected = tree.getAffectedListeners(['colors', 'green']); + expect(affected).eql([]); + }); + it('returns all direct listeners', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener([], 'listener2'); + var affected = tree.getAffectedListeners([]); + expect(affected).eql(['listener1', 'listener2']); + }); + it('removeListener stops listener from being returned', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener([], 'listener2'); + tree.removeListener([], 'listener1'); + var affected = tree.getAffectedListeners([]); + expect(affected).eql(['listener2']); + }); + it('returns all descendant listeners', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'green'], 'listener1'); + tree.addListener(['colors', 'red'], 'listener2'); + tree.addListener(['colors', 'red'], 'listener3'); + tree.addListener([], 'listener4'); + tree.addListener(['colors'], 'listener5'); + var affected = tree.getAffectedListeners([]); + expect(affected).eql(['listener4', 'listener5', 'listener1', 'listener2', 'listener3']); + }); + it('returns all parent listeners in depth order', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'green'], 'listener1'); + tree.addListener(['colors', 'red'], 'listener2'); + tree.addListener(['colors', 'red'], 'listener3'); + tree.addListener([], 'listener4'); + tree.addListener(['colors'], 'listener5'); + var affected = tree.getAffectedListeners(['colors', 'green']); + expect(affected).eql(['listener4', 'listener5', 'listener1']); + }); + it('does not return peers or peer children', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener(['colors'], 'listener2'); + tree.addListener(['colors', 'green'], 'listener3'); + tree.addListener(['textures'], 'listener4'); + tree.addListener(['textures', 'smooth'], 'listener5'); + var affected = tree.getAffectedListeners(['textures']); + expect(affected).eql(['listener1', 'listener4', 'listener5']); + }); + }); + describe('getDescendantListeners', function() { + it('returns empty array without listeners', function() { + var tree = new EventListenerTree(); + var affected = tree.getDescendantListeners([]); + expect(affected).eql([]); + }); + it('returns empty array on path without node', function() { + var tree = new EventListenerTree(); + var affected = tree.getDescendantListeners(['colors', 'green']); + expect(affected).eql([]); + }); + it('does not return direct listeners', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener([], 'listener2'); + var affected = tree.getDescendantListeners([]); + expect(affected).eql([]); + }); + it('returns all descendant listeners', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'green'], 'listener1'); + tree.addListener(['colors', 'red'], 'listener2'); + tree.addListener(['colors', 'red'], 'listener3'); + tree.addListener([], 'listener4'); + tree.addListener(['colors'], 'listener5'); + var affected = tree.getDescendantListeners([]); + expect(affected).eql(['listener5', 'listener1', 'listener2', 'listener3']); + }); + it('does not return parent or peer listeners', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener(['colors'], 'listener2'); + tree.addListener(['colors', 'green'], 'listener3'); + tree.addListener(['textures'], 'listener4'); + tree.addListener(['textures', 'smooth'], 'listener5'); + var affected = tree.getDescendantListeners(['textures']); + expect(affected).eql(['listener5']); + }); + }); + describe('getWildcardListeners', function() { + it('returns empty array without listeners', function() { + var tree = new EventListenerTree(); + var affected = tree.getWildcardListeners([]); + expect(affected).eql([]); + }); + it('returns exact match on root', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + var affected = tree.getWildcardListeners([]); + expect(affected).eql(['listener1']); + }); + it('returns exact match on subpath', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'green'], 'listener1'); + var affected = tree.getWildcardListeners(['colors', 'green']); + expect(affected).eql(['listener1']); + }); + it('does not match parent, descendant, or peer listeners', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener(['colors'], 'listener2'); + tree.addListener(['colors', 'green'], 'listener3'); + tree.addListener(['colors', 'green', 'hex'], 'listener4'); + tree.addListener(['colors', 'red'], 'listener5'); + var affected = tree.getWildcardListeners(['colors', 'green']); + expect(affected).eql(['listener3']); + }); + it('returns * match on first segment', function() { + var tree = new EventListenerTree(); + tree.addListener(['*'], 'listener1'); + var affected = tree.getWildcardListeners(['colors']); + expect(affected).eql(['listener1']); + }); + it('returns * match at subpath', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', '*'], 'listener1'); + tree.addListener(['textures', '*'], 'listener2'); + var affected = tree.getWildcardListeners(['colors', 'green']); + expect(affected).eql(['listener1']); + }); + it('returns * match at parent', function() { + var tree = new EventListenerTree(); + tree.addListener(['*', 'green'], 'listener1'); + tree.addListener(['*', 'red'], 'listener2'); + var affected = tree.getWildcardListeners(['colors', 'green']); + expect(affected).eql(['listener1']); + }); + it('returns multiple * match', function() { + var tree = new EventListenerTree(); + tree.addListener(['*', '*'], 'listener1'); + var affected = tree.getWildcardListeners(['colors', 'green']); + expect(affected).eql(['listener1']); + }); + it('does not * match parent, descendant, or peer listeners', function() { + var tree = new EventListenerTree(); + tree.addListener([], 'listener1'); + tree.addListener(['*'], 'listener2'); + tree.addListener(['*', '*'], 'listener3'); + tree.addListener(['*', '*', '*'], 'listener4'); + var affected = tree.getWildcardListeners(['colors', 'green']); + expect(affected).eql(['listener3']); + }); + it('returns multiple * matches together', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', 'green'], 'listener1'); + tree.addListener(['*', 'green'], 'listener2'); + tree.addListener(['colors', '*'], 'listener3'); + tree.addListener(['colors', '*'], 'listener4'); + var affected = tree.getWildcardListeners(['colors', 'green']).sort(); + expect(affected).eql(['listener1', 'listener2', 'listener3', 'listener4']); + }); + it('returns ** match on root', function() { + var tree = new EventListenerTree(); + tree.addListener(['**'], 'listener1'); + var affected = tree.getWildcardListeners([]); + expect(affected).eql(['listener1']); + }); + it('returns ** match on first segment', function() { + var tree = new EventListenerTree(); + tree.addListener(['**'], 'listener1'); + var affected = tree.getWildcardListeners(['colors']); + expect(affected).eql(['listener1']); + }); + it('returns ** match on descendant', function() { + var tree = new EventListenerTree(); + tree.addListener(['**'], 'listener1'); + var affected = tree.getWildcardListeners(['colors', 'green']); + expect(affected).eql(['listener1']); + }); + it('returns exact match followed by **', function() { + var tree = new EventListenerTree(); + tree.addListener(['colors', '**'], 'listener1'); + var affected = tree.getWildcardListeners(['colors']); + expect(affected).eql(['listener1']); + }); + it('does not ** match peer or descendant listeners', function() { + var tree = new EventListenerTree(); + tree.addListener(['**'], 'listener1'); + tree.addListener(['colors', '**'], 'listener2'); + tree.addListener(['colors', 'green', '**'], 'listener3'); + tree.addListener(['textures', '**'], 'listener4'); + var affected = tree.getWildcardListeners(['colors']); + expect(affected).eql(['listener1', 'listener2']); + }); + }); +}); diff --git a/test/Model/EventMapTree.js b/test/Model/EventMapTree.js new file mode 100644 index 000000000..02a60fe21 --- /dev/null +++ b/test/Model/EventMapTree.js @@ -0,0 +1,263 @@ +var expect = require('../util').expect; +var {EventMapTree} = require('../../lib/Model/EventMapTree'); + +describe('EventMapTree', function() { + describe('setListener', function() { + it('sets a listener object at the root', function() { + var tree = new EventMapTree(); + var listener = {}; + tree.setListener([], listener); + expect(tree.getListener([])).equal(listener); + expect(tree.children).equal(null); + }); + it('setting returns the previous listener', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + var previous = tree.setListener([], listener1); + expect(previous).equal(null); + expect(tree.getListener([])).equal(listener1); + var previous = tree.setListener([], listener2); + expect(previous).equal(listener1); + expect(tree.getListener([])).equal(listener2); + expect(tree.children).equal(null); + }); + it('sets a listener object at a path', function() { + var tree = new EventMapTree(); + var listener = {}; + tree.setListener(['colors'], listener); + expect(tree.getListener([])).equal(null); + expect(tree.getListener(['colors'])).equal(listener); + }); + it('sets a listener object at a subpath', function() { + var tree = new EventMapTree(); + var listener = {}; + tree.setListener(['colors', 'green'], listener); + expect(tree.getListener([])).equal(null); + expect(tree.getListener(['colors'])).equal(null); + expect(tree.getListener(['colors', 'green'])).equal(listener); + }); + }); + describe('destroy', function() { + it('can be called on empty root', function() { + var tree = new EventMapTree(); + tree.destroy(); + expect(tree.children).eql(null); + }); + it('removes nodes up to root', function() { + var tree = new EventMapTree(); + tree.setListener(['colors', 'green'], 'listener1'); + var node = tree._getChild(['colors', 'green']); + node.destroy(); + expect(tree.children).eql(null); + }); + it('can be called on child node repeatedly', function() { + var tree = new EventMapTree(); + tree.setListener(['colors', 'green'], 'listener1'); + var node = tree._getChild(['colors', 'green']); + node.destroy(); + node.destroy(); + }); + it('does not remove parent nodes with existing listeners', function() { + var tree = new EventMapTree(); + tree.setListener(['colors'], 'listener1'); + tree.setListener(['colors', 'green'], 'listener2'); + var node = tree._getChild(['colors', 'green']); + node.destroy(); + node.destroy(); + expect(tree.getListener(['colors'])).eql('listener1'); + }); + it('does not remove parent nodes with other children', function() { + var tree = new EventMapTree(); + tree.setListener(['colors', 'red'], 'listener1'); + tree.setListener(['colors', 'green'], 'listener2'); + var node = tree._getChild(['colors', 'green']); + node.destroy(); + node.destroy(); + expect(tree.getListener(['colors', 'red'])).eql('listener1'); + }); + }); + describe('deleteListener', function() { + it('can be called before setListener', function() { + var tree = new EventMapTree(); + tree.deleteListener(['colors', 'green']); + expect(tree.getListener(['colors', 'green'])).equal(null); + expect(tree.children).equal(null); + }); + it('deletes listener at root', function() { + var tree = new EventMapTree(); + var listener = {}; + tree.setListener([], listener); + expect(tree.getListener([])).equal(listener); + var previous = tree.deleteListener([]); + expect(previous).equal(listener); + expect(tree.getListener([])).equal(null); + }); + it('deletes listener at subpath', function() { + var tree = new EventMapTree(); + var listener = {}; + tree.setListener(['colors', 'green'], listener); + expect(tree.getListener(['colors', 'green'])).equal(listener); + var previous = tree.deleteListener(['colors', 'green']); + expect(previous).equal(listener); + expect(tree.children).equal(null); + }); + it('deletes listener with remaining children', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + tree.setListener(['colors'], listener1); + tree.setListener(['colors', 'green'], listener2); + expect(tree.getListener(['colors'])).equal(listener1); + expect(tree.getListener(['colors', 'green'])).equal(listener2); + tree.deleteListener(['colors']); + expect(tree.getListener(['colors'])).equal(null); + expect(tree.getListener(['colors', 'green'])).equal(listener2); + }); + it('deletes listener with remaining peers', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + tree.setListener(['colors', 'red'], listener1); + tree.setListener(['colors', 'green'], listener2); + expect(tree.getListener(['colors', 'red'])).equal(listener1); + expect(tree.getListener(['colors', 'green'])).equal(listener2); + tree.deleteListener(['colors', 'red']); + expect(tree.getListener(['colors', 'red'])).equal(null); + expect(tree.getListener(['colors', 'green'])).equal(listener2); + }); + }); + describe('deleteAllListeners', function() { + it('can be called on empty root', function() { + var tree = new EventMapTree(); + tree.deleteAllListeners([]); + }); + it('can be called on missing node', function() { + var tree = new EventMapTree(); + tree.deleteAllListeners(['colors', 'green']); + }); + it('deletes all listeners and children when called on root', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + var listener3 = 'listener3'; + tree.setListener([], listener1); + tree.setListener(['colors'], listener2); + tree.setListener(['colors', 'green'], listener3); + tree.deleteAllListeners([]); + expect(tree.getListener([])).equal(null); + expect(tree.children).equal(null); + }); + it('deletes listeners and descendent children on path', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + var listener3 = 'listener3'; + tree.setListener([], listener1); + tree.setListener(['colors'], listener2); + tree.setListener(['colors', 'green'], listener3); + tree.deleteAllListeners(['colors']); + expect(tree.getListener([])).equal(listener1); + expect(tree.children).equal(null); + }); + }); + describe('getAffectedListeners', function() { + it('returns empty array without listeners', function() { + var tree = new EventMapTree(); + var affected = tree.getAffectedListeners([]); + expect(affected).eql([]); + }); + it('returns empty array on path without node', function() { + var tree = new EventMapTree(); + var affected = tree.getAffectedListeners(['colors', 'green']); + expect(affected).eql([]); + }); + it('returns all direct listeners', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + tree.setListener([], listener1); + var affected = tree.getAffectedListeners([]); + expect(affected).eql([listener1]); + }); + it('deleteListener stops listener from being returned', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + tree.setListener([], listener1); + tree.deleteListener([], listener1); + var affected = tree.getAffectedListeners([]); + expect(affected).eql([]); + }); + it('returns all descendant listeners', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + var listener3 = 'listener3'; + var listener4 = 'listener4'; + tree.setListener(['colors', 'green'], listener1); + tree.setListener(['colors', 'red'], listener2); + tree.setListener([], listener3); + tree.setListener(['colors'], listener4); + var affected = tree.getAffectedListeners([]); + expect(affected).eql([listener3, listener4, listener1, listener2]); + }); + it('returns all parent listeners in depth order', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + var listener3 = 'listener3'; + var listener4 = 'listener4'; + tree.setListener(['colors', 'green'], listener1); + tree.setListener(['colors', 'red'], listener2); + tree.setListener([], listener3); + tree.setListener(['colors'], listener4); + var affected = tree.getAffectedListeners(['colors', 'green']); + expect(affected).eql([listener3, listener4, listener1]); + }); + it('does not return peers or peer children', function() { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + var listener3 = 'listener3'; + var listener4 = 'listener4'; + var listener5 = 'listener5'; + tree.setListener([], listener1); + tree.setListener(['colors'], listener2); + tree.setListener(['colors', 'green'], listener3); + tree.setListener(['textures'], listener4); + tree.setListener(['textures', 'smooth'], listener5); + var affected = tree.getAffectedListeners(['textures']); + expect(affected).eql([listener1, listener4, listener5]); + }); + }); + describe('forEach', function() { + it('can be called on empty tree', function() { + var tree = new EventMapTree(); + tree.forEach(function() { + throw new Error('Unexpected call'); + }); + }); + it('calls back with direct listener', function(done) { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + tree.setListener([], listener1); + tree.forEach(function(listener) { + expect(listener).equal(listener1); + done(); + }); + }); + it('calls back with each descendant listener', function(done) { + var tree = new EventMapTree(); + var listener1 = 'listener1'; + var listener2 = 'listener2'; + var listener3 = 'listener3'; + tree.setListener(['colors'], listener1); + tree.setListener(['colors', 'green'], listener2); + tree.setListener(['colors', 'red'], listener3); + var expected = [listener1, listener2, listener3]; + tree.forEach(function(listener) { + expect(listener).equal(expected.shift()); + if (expected.length === 0) done(); + }); + }); + }); +}); diff --git a/test/Model/LocalDoc.js b/test/Model/LocalDoc.js new file mode 100644 index 000000000..3022da7b3 --- /dev/null +++ b/test/Model/LocalDoc.js @@ -0,0 +1,22 @@ +var expect = require('../util').expect; +var {LocalDoc} = require('../../lib/Model/LocalDoc'); +var docs = require('./docs'); + +describe('LocalDoc', function() { + function createDoc() { + var modelMock = { + data: { + _colors: {} + } + }; + return new LocalDoc(modelMock, '_colors', 'green'); + } + describe('create', function() { + it('should set the collectionName and id properties', function() { + var doc = createDoc(); + expect(doc.collectionName).to.equal('_colors'); + expect(doc.id).to.equal('green'); + }); + }); + docs(createDoc); +}); diff --git a/test/Model/LocalDoc.mocha.coffee b/test/Model/LocalDoc.mocha.coffee deleted file mode 100644 index ca44e7d1f..000000000 --- a/test/Model/LocalDoc.mocha.coffee +++ /dev/null @@ -1,19 +0,0 @@ -{expect} = require '../util' -LocalDoc = require '../../lib/Model/LocalDoc' -docs = require './docs' - -describe 'LocalDoc', -> - - createDoc = -> - modelMock = - data: - _colors: {} - new LocalDoc modelMock, '_colors', 'green' - - describe 'create', -> - it 'should set the collectionName and id properties', -> - doc = createDoc() - expect(doc.collectionName).to.equal '_colors' - expect(doc.id).to.equal 'green' - - docs createDoc diff --git a/test/Model/MockConnectionModel.coffee b/test/Model/MockConnectionModel.coffee deleted file mode 100644 index cdc177314..000000000 --- a/test/Model/MockConnectionModel.coffee +++ /dev/null @@ -1,17 +0,0 @@ -share = require 'share' -Model = require '../../lib/Model' - -# Mock up a connection with a fake socket -module.exports = MockConnectionModel = -> - Model.apply this, arguments -MockConnectionModel:: = Object.create Model:: -MockConnectionModel::createConnection = -> - socketMock = - send: (message) -> - close: -> - onmessage: -> - onclose: -> - onerror: -> - onopen: -> - onconnecting: -> - @root.shareConnection = new share.client.Connection(socketMock) diff --git a/test/Model/RemoteDoc.js b/test/Model/RemoteDoc.js new file mode 100644 index 000000000..af10b94f7 --- /dev/null +++ b/test/Model/RemoteDoc.js @@ -0,0 +1,112 @@ +var expect = require('../util').expect; +var racer = require('../../lib/index'); +var docs = require('./docs'); + +describe('RemoteDoc', function() { + function createDoc() { + var backend = racer.createBackend(); + var model = backend.createModel(); + var doc = model.getOrCreateDoc('colors', 'green'); + doc.create(); + return doc; + } + + describe('create', function() { + it('should set the collectionName and id properties', function() { + var doc = createDoc(); + expect(doc.collectionName).to.equal('colors'); + expect(doc.id).to.equal('green'); + }); + }); + + describe('preventCompose', function() { + beforeEach(function() { + this.backend = racer.createBackend(); + this.model = this.backend.createModel(); + }); + + it('composes ops by default', function(done) { + var fido = this.model.at('dogs.fido'); + var doc = this.model.connection.get('dogs', 'fido'); + fido.create({age: 3}); + fido.increment('age', 2); + fido.increment('age', 2, function(err) { + if (err) return done(err); + expect(doc.version).equal(1); + expect(fido.get()).eql({id: 'fido', age: 7}); + fido.increment('age', 2); + fido.increment('age', 2, function(err) { + if (err) return done(err); + expect(doc.version).equal(2); + expect(fido.get()).eql({id: 'fido', age: 11}); + done(); + }); + }); + }); + + it('does not compose ops on a model.preventCompose() child model', function(done) { + var fido = this.model.at('dogs.fido').preventCompose(); + var doc = this.model.connection.get('dogs', 'fido'); + fido.create({age: 3}); + fido.increment('age', 2); + fido.increment('age', 2, function(err) { + if (err) return done(err); + expect(doc.version).equal(3); + expect(fido.get()).eql({id: 'fido', age: 7}); + fido.increment('age', 2); + fido.increment('age', 2, function(err) { + if (err) return done(err); + expect(doc.version).equal(5); + expect(fido.get()).eql({id: 'fido', age: 11}); + done(); + }); + }); + }); + + it('composes ops on a model.allowCompose() child model', function(done) { + var fido = this.model.at('dogs.fido').preventCompose(); + var doc = this.model.connection.get('dogs', 'fido'); + fido.create({age: 3}); + fido.increment('age', 2); + fido.increment('age', 2, function(err) { + if (err) return done(err); + expect(doc.version).equal(3); + expect(fido.get()).eql({id: 'fido', age: 7}); + fido = fido.allowCompose(); + fido.increment('age', 2); + fido.increment('age', 2, function(err) { + if (err) return done(err); + expect(doc.version).equal(4); + expect(fido.get()).eql({id: 'fido', age: 11}); + done(); + }); + }); + }); + }); + + describe('promised operations', function() { + beforeEach(function() { + this.backend = racer.createBackend(); + this.model = this.backend.createModel(); + }); + + it('composes sequential operations', async function() { + var model = this.model; + await model.addPromised('notes', {id: 'my-note', score: 1}); + var $note = model.at('notes.my-note'); + var shareDoc = model.connection.get('notes', 'my-note'); + expect(shareDoc).to.have.property('version', 1); + await Promise.all([ + $note.pushPromised('labels', 'Label A'), + $note.incrementPromised('score', 2), + $note.pushPromised('labels', 'Label B') + ]); + // Writes initiated in the same event loop should be composed into a single op + expect(shareDoc).to.have.property('version', 2); + expect($note.get('labels')).to.eql(['Label A', 'Label B']); + expect($note.get('score')).to.equal(3); + }); + }); + + docs(createDoc); +}); diff --git a/test/Model/RemoteDoc.mocha.coffee b/test/Model/RemoteDoc.mocha.coffee deleted file mode 100644 index 6d40748fa..000000000 --- a/test/Model/RemoteDoc.mocha.coffee +++ /dev/null @@ -1,20 +0,0 @@ -{expect} = require '../util' -Model = require './MockConnectionModel' -RemoteDoc = require '../../lib/Model/RemoteDoc' -docs = require './docs' - -describe 'RemoteDoc', -> - - createDoc = -> - model = new Model - model.createConnection() - model.data.colors = {} - return new RemoteDoc model, 'colors', 'green' - - describe 'create', -> - it 'should set the collectionName and id properties', -> - doc = createDoc() - expect(doc.collectionName).to.equal 'colors' - expect(doc.id).to.equal 'green' - - docs createDoc diff --git a/test/Model/bundle.js b/test/Model/bundle.js new file mode 100644 index 000000000..eafefa54b --- /dev/null +++ b/test/Model/bundle.js @@ -0,0 +1,72 @@ +var expect = require('../util').expect; +var racer = require('../../lib/index'); + +describe('bundle', function() { + it('does not serialize Share docs with null versions and null type', function(done) { + var backend = racer.createBackend(); + var setupModel = backend.createModel(); + setupModel.add('dogs', {id: 'coco', name: 'Coco'}); + setupModel.whenNothingPending(function() { + var model1 = backend.createModel({fetchOnly: true}); + + // This creates a Share client Doc on the connection, with null version and data. + model1.connection.get('dogs', 'fido'); + // Fetching a non-existent id results in a Share Doc with version 0 and undefined data. + model1.fetch('dogs.spot'); + // This doc should be properly fetched and bundled. + model1.fetch('dogs.coco'); + + model1.whenNothingPending(function(err) { + if (err) return done(err); + model1.bundle(function(err, bundleData) { + if (err) return done(err); + // Simulate serialization of bundle data between server and client. + bundleData = JSON.parse(JSON.stringify(bundleData)); + + var model2 = backend.createModel(); + model2.unbundle(bundleData); + expect(model2.get('dogs.fido')).to.equal(undefined); + expect(model2.get('dogs.spot')).to.equal(undefined); + expect(model2.get('dogs.coco')).to.eql({id: 'coco', name: 'Coco'}); + done(); + }); + }); + }); + }); + + it('does not serialize ref outputs', function(done) { + var backend = racer.createBackend(); + var model = backend.createModel(); + model.add('dogs', {id: 'coco', name: 'Coco'}); + model.ref('_page.myDog', 'dogs.coco'); + model.bundle(function(err, bundleData) { + if (err) return done(err); + // Simulate serialization of bundle data between server and client + bundleData = JSON.parse(JSON.stringify(bundleData)); + + // Bundle should not have data duplicated on the ref output path + expect(bundleData.collections).to.not.have.property('_page'); + + // After unbundling, the ref should be re-created + var clientModel = backend.createModel(); + clientModel.unbundle(bundleData); + expect(clientModel.get('_page')).to.deep.equal({myDog: {id: 'coco', name: 'Coco'}}); + // Test that operation on ref output path is applied to the backing doc + clientModel.set('_page.myDog.breed', 'poodle'); + expect(clientModel.get('dogs.coco')).to.deep.equal({id: 'coco', name: 'Coco', breed: 'poodle'}); + done(); + }); + }); + + describe('bundlePromised', function() { + it('awaits bundle completion', async function() { + var backend = racer.createBackend(); + var model = backend.createModel(); + model.add('dogs', {id: 'coco', name: 'Coco'}); + var bundled = await model.bundlePromised(); + var model2 = backend.createModel(); + model2.unbundle(bundled); + expect(model2.get('dogs.coco')).to.deep.equal(model.get('dogs.coco')); + }); + }); +}); diff --git a/test/Model/collections.js b/test/Model/collections.js new file mode 100644 index 000000000..b418574f7 --- /dev/null +++ b/test/Model/collections.js @@ -0,0 +1,83 @@ +const {expect} = require('../util'); +const {RootModel} = require('../../lib'); + +describe('collections', () => { + describe('getOrDefault', () => { + it('returns value if defined', () => { + const model = new RootModel(); + model.add('_test_doc', {name: 'foo'}); + const value = model.getOrDefault('_test_doc', {name: 'bar'}); + expect(value).not.to.be.undefined; + }); + + it('returns defuault value if undefined', () => { + const model = new RootModel(); + const defaultValue = {name: 'bar'}; + const value = model.getOrDefault('_test_doc', defaultValue); + expect(value).not.to.be.undefined; + expect(value.name).to.equal('bar'); + expect(value).to.eql(defaultValue); + }); + + it('returns default value if null', () => { + const model = new RootModel(); + const id = model.add('_test_doc', {name: null}); + const defaultValue = 'bar'; + const value = model.getOrDefault(`_test_doc.${id}.name`, defaultValue); + expect(value).not.to.be.null; + expect(value).to.equal('bar'); + expect(value).to.eql(defaultValue); + }); + }); + + describe('getOrThrow', () => { + it('returns value if defined', () => { + const model = new RootModel(); + model.add('_test_doc', {name: 'foo'}); + const value = model.getOrThrow('_test_doc'); + expect(value).not.to.be.undefined; + }); + + it('throws if value undefined', () => { + const model = new RootModel(); + expect(() => model.getOrThrow('_test_doc')).to.throw(`No value at path _test_doc`); + expect(() => model.scope('_test').getOrThrow('doc.1')).to.throw(`No value at path _test.doc.1`); + }); + + it('throws if value null', () => { + const model = new RootModel(); + const id = model.add('_test_doc', {name: null}); + expect(model.getOrThrow(`_test_doc.${id}`)).to.eql({id, name: null}); + expect(() => model.getOrThrow(`_test_doc.${id}.name`)).to.throw(`No value at path _test_doc`); + }); + }); + + describe('getValues', () => { + it('returns array of values from collection', () => { + const model = new RootModel(); + model.add('_test_docs', {name: 'foo'}); + model.add('_test_docs', {name: 'bar'}); + const values = model.getValues('_test_docs'); + expect(values).to.be.instanceOf(Array); + expect(values).to.have.lengthOf(2); + ['foo', 'bar'].forEach((value, index) => { + expect(values[index]).to.have.property('name', value); + }); + }); + + it('return empty array when no values at subpath', () => { + const model = new RootModel(); + const values = model.getValues('_test_docs'); + expect(values).to.be.instanceOf(Array); + expect(values).to.have.lengthOf(0); + }); + + it('throws error if non-object result at path', () => { + const model = new RootModel(); + const id = model.add('_colors', {rgb: 3}); + expect( + () => model.getValues(`_colors.${id}.rgb`) + ).to.throw(`Found non-object type for getValues('_colors.${id}.rgb')`); + }); + }); +}); diff --git a/test/Model/connection.js b/test/Model/connection.js new file mode 100644 index 000000000..ee01749a6 --- /dev/null +++ b/test/Model/connection.js @@ -0,0 +1,23 @@ +var expect = require('../util').expect; +var racer = require('../../lib/index'); + +describe('connection', function() { + describe('getAgent', function() { + it('returns a reference to the ShareDB agent on the server', function() { + var backend = racer.createBackend(); + var model = backend.createModel(); + var agent = model.getAgent(); + expect(agent).to.be.ok; + }); + + it('returns null once the model is disconnected', function(done) { + var backend = racer.createBackend(); + var model = backend.createModel(); + model.close(function() { + var agent = model.getAgent(); + expect(agent).equal(null); + done(); + }); + }); + }); +}); diff --git a/test/Model/docs.coffee b/test/Model/docs.coffee deleted file mode 100644 index 9c882404c..000000000 --- a/test/Model/docs.coffee +++ /dev/null @@ -1,187 +0,0 @@ -{expect} = require '../util' - -module.exports = (createDoc) -> - - describe 'get', -> - - it 'creates an undefined doc', -> - doc = createDoc() - expect(doc.get()).eql undefined - - it 'gets a defined doc', -> - doc = createDoc() - doc.set [], {id: 'green'}, -> - expect(doc.get()).eql {id: 'green'} - - it 'gets a property on an undefined document', -> - doc = createDoc() - expect(doc.get ['x']).eql undefined - - it 'gets an undefined property', -> - doc = createDoc() - doc.set [], {}, -> - expect(doc.get ['x']).eql undefined - - it 'gets a defined property', -> - doc = createDoc() - doc.set [], {'id': 'green'}, -> - expect(doc.get ['id']).eql 'green' - - it 'gets a false property', -> - doc = createDoc() - doc.set [], {shown: false}, -> - expect(doc.get ['shown']).eql false - - it 'gets a null property', -> - doc = createDoc() - doc.set [], {shown: null}, -> - expect(doc.get ['shown']).eql null - - it 'gets a method property', -> - doc = createDoc() - doc.set [], {empty: ''}, -> - expect(doc.get ['empty', 'charAt']).eql ''.charAt - - it 'gets an array member', -> - doc = createDoc() - doc.set [], {rgb: [0, 255, 0]}, -> - expect(doc.get ['rgb', '1']).eql 255 - - it 'gets an array length', -> - doc = createDoc() - doc.set [], {rgb: [0, 255, 0]}, -> - expect(doc.get ['rgb', 'length']).eql 3 - - describe 'set', -> - - it 'sets a property', -> - doc = createDoc() - previous = doc.set ['shown'], false, -> - expect(previous).equal undefined - expect(doc.get(['shown'])).eql false - - it 'sets a multi-nested property', -> - doc = createDoc() - previous = doc.set ['rgb', 'green', 'float'], 1, -> - expect(previous).equal undefined - expect(doc.get(['rgb'])).eql {green: {float: 1}} - - it 'sets on an existing document', -> - doc = createDoc() - previous = doc.set [], {id: 'green'}, -> - expect(previous).equal undefined - expect(doc.get()).eql {id: 'green'} - previous = doc.set ['shown'], false, -> - expect(previous).equal undefined - expect(doc.get()).eql {id: 'green', shown: false} - - it 'returns the previous value on set', -> - doc = createDoc() - previous = doc.set ['shown'], false, -> - expect(previous).equal undefined - expect(doc.get(['shown'])).eql false - previous = doc.set ['shown'], true, -> - expect(previous).equal false - expect(doc.get(['shown'])).eql true - - it 'creates an implied array on set', -> - doc = createDoc() - doc.set ['rgb', '2'], 0, -> - doc.set ['rgb', '1'], 255, -> - doc.set ['rgb', '0'], 127, -> - expect(doc.get(['rgb'])).eql [127, 255, 0] - - it 'creates an implied object on an array', -> - doc = createDoc() - doc.set ['colors'], [], -> - doc.set ['colors', '0', 'value'], 'green', -> - expect(doc.get(['colors'])).eql [{value: 'green'}] - - describe 'del', -> - - it 'can del on an undefined path without effect', -> - doc = createDoc() - previous = doc.del ['rgb', '2'], -> - expect(previous).equal undefined - expect(doc.get()).eql undefined - - it 'can del on a document', -> - doc = createDoc() - doc.set [], {id: 'green'}, -> - previous = doc.del [], -> - expect(previous).eql {id: 'green'} - expect(doc.get()).eql undefined - - it 'can del on a nested property', -> - doc = createDoc() - doc.set ['rgb'], [ - {float: 0, int: 0} - {float: 1, int: 255} - {float: 0, int: 0} - ], -> - previous = doc.del ['rgb', '0', 'float'], -> - expect(previous).eql 0 - expect(doc.get ['rgb']).eql [ - {int: 0} - {float: 1, int: 255} - {float: 0, int: 0} - ] - - describe 'push', -> - - it 'can push on an undefined property', -> - doc = createDoc() - len = doc.push ['friends'], 'jim', -> - expect(len).equal 1 - expect(doc.get(['friends'])).eql ['jim'] - - it 'can push on a defined array', -> - doc = createDoc() - len = doc.push ['friends'], 'jim', -> - expect(len).equal 1 - len = doc.push ['friends'], 'sue', -> - expect(len).equal 2 - expect(doc.get(['friends'])).eql ['jim', 'sue'] - - it 'throws a TypeError when pushing on a non-array', (done) -> - doc = createDoc() - doc.set ['friends'], {}, -> - doc.push ['friends'], ['x'], (err) -> - expect(err).a TypeError - done() - - describe 'move', -> - - it 'can move an item from the end to the beginning of the array', -> - doc = createDoc() - doc.set ['array'], [0, 1, 2, 3, 4], -> - - moved = doc.move ['array'], 4, 0, 1, -> - expect(moved).eql [4] - expect(doc.get(['array'])).eql [4, 0, 1, 2, 3] - - it 'can swap the first two items in the array', -> - doc = createDoc() - doc.set ['array'], [0, 1, 2, 3, 4], -> - - moved = doc.move ['array'], 1, 0, 1, -> - expect(moved).eql [1] - expect(doc.get(['array'])).eql [1, 0, 2, 3, 4] - - it 'can move an item from the begnning to the end of the array', -> - doc = createDoc() - doc.set ['array'], [0, 1, 2, 3, 4], -> - - # note that destination is index after removal of item - moved = doc.move ['array'], 0, 4, 1, -> - expect(moved).eql [0] - expect(doc.get(['array'])).eql [1, 2, 3, 4, 0] - - it 'can move several items mid-array, with an event for each', -> - doc = createDoc() - doc.set ['array'], [0, 1, 2, 3, 4], -> - - # note that destination is index after removal of items - moved = doc.move ['array'], 1, 3, 2, -> - expect(moved).eql [1, 2] - expect(doc.get(['array'])).eql [0, 3, 4, 1, 2] diff --git a/test/Model/docs.js b/test/Model/docs.js new file mode 100644 index 000000000..d326044f3 --- /dev/null +++ b/test/Model/docs.js @@ -0,0 +1,231 @@ +var expect = require('../util').expect; + +module.exports = function(createDoc) { + describe('get', function() { + it('creates an undefined doc', function() { + var doc = createDoc(); + expect(doc.get()).eql(undefined); + }); + it('gets a defined doc', function() { + var doc = createDoc(); + doc.set([], { + id: 'green' + }, function() {}); + expect(doc.get()).eql({ + id: 'green' + }); + }); + it('gets a property on an undefined document', function() { + var doc = createDoc(); + expect(doc.get(['x'])).eql(undefined); + }); + it('gets an undefined property', function() { + var doc = createDoc(); + doc.set([], {}, function() {}); + expect(doc.get(['x'])).eql(undefined); + }); + it('gets a defined property', function() { + var doc = createDoc(); + doc.set([], { + id: 'green' + }, function() {}); + expect(doc.get(['id'])).eql('green'); + }); + it('gets a false property', function() { + var doc = createDoc(); + doc.set([], { + shown: false + }, function() {}); + expect(doc.get(['shown'])).eql(false); + }); + it('gets a null property', function() { + var doc = createDoc(); + doc.set([], { + shown: null + }, function() {}); + expect(doc.get(['shown'])).eql(null); + }); + it('gets a method property', function() { + var doc = createDoc(); + doc.set([], { + empty: '' + }, function() {}); + expect(doc.get(['empty', 'charAt'])).eql(''.charAt); + }); + it('gets an array member', function() { + var doc = createDoc(); + doc.set([], { + rgb: [0, 255, 0] + }, function() {}); + expect(doc.get(['rgb', '1'])).eql(255); + }); + it('gets an array length', function() { + var doc = createDoc(); + doc.set([], { + rgb: [0, 255, 0] + }, function() {}); + expect(doc.get(['rgb', 'length'])).eql(3); + }); + }); + describe('set', function() { + it('sets a property', function() { + var doc = createDoc(); + var previous = doc.set(['shown'], false, function() {}); + expect(previous).equal(undefined); + expect(doc.get(['shown'])).eql(false); + }); + it('sets a multi-nested property', function() { + var doc = createDoc(); + var previous = doc.set(['rgb', 'green', 'float'], 1, function() {}); + expect(previous).equal(undefined); + expect(doc.get(['rgb'])).eql({ + green: { + float: 1 + } + }); + }); + it('sets on an existing document', function() { + var doc = createDoc(); + var previous = doc.set([], { + id: 'green' + }, function() {}); + expect(previous).equal(undefined); + expect(doc.get()).eql({ + id: 'green' + }); + previous = doc.set(['shown'], false, function() {}); + expect(previous).equal(undefined); + expect(doc.get()).eql({ + id: 'green', + shown: false + }); + }); + it('returns the previous value on set', function() { + var doc = createDoc(); + var previous = doc.set(['shown'], false, function() {}); + expect(previous).equal(undefined); + expect(doc.get(['shown'])).eql(false); + previous = doc.set(['shown'], true, function() {}); + expect(previous).equal(false); + expect(doc.get(['shown'])).eql(true); + }); + it('creates an implied array on set', function() { + var doc = createDoc(); + doc.set(['rgb', '2'], 0, function() {}); + doc.set(['rgb', '1'], 255, function() {}); + doc.set(['rgb', '0'], 127, function() {}); + expect(doc.get(['rgb'])).eql([127, 255, 0]); + }); + it('creates an implied object on an array', function() { + var doc = createDoc(); + doc.set(['colors'], [], function() {}); + doc.set(['colors', '0', 'value'], 'green', function() {}); + expect(doc.get(['colors'])).eql([ + { + value: 'green' + } + ]); + }); + }); + describe('del', function() { + it('can del on an undefined path without effect', function() { + var doc = createDoc(); + var previous = doc.del(['rgb', '2'], function() {}); + expect(previous).equal(undefined); + expect(doc.get()).eql(undefined); + }); + it('can del on a document', function() { + var doc = createDoc(); + doc.set([], { + id: 'green' + }, function() {}); + var previous = doc.del([], function() {}); + expect(previous).eql({ + id: 'green' + }); + expect(doc.get()).eql(undefined); + }); + it('can del on a nested property', function() { + var doc = createDoc(); + doc.set(['rgb'], [ + { + float: 0, + int: 0 + }, { + float: 1, + int: 255 + }, { + float: 0, + int: 0 + } + ], function() {}); + var previous = doc.del(['rgb', '0', 'float'], function() {}); + expect(previous).eql(0); + expect(doc.get(['rgb'])).eql([ + { + int: 0 + }, { + float: 1, + int: 255 + }, { + float: 0, + int: 0 + } + ]); + }); + }); + describe('push', function() { + it('can push on an undefined property', function() { + var doc = createDoc(); + var len = doc.push(['friends'], 'jim', function() {}); + expect(len).equal(1); + expect(doc.get(['friends'])).eql(['jim']); + }); + it('can push on a defined array', function() { + var doc = createDoc(); + var len = doc.push(['friends'], 'jim', function() {}); + expect(len).equal(1); + len = doc.push(['friends'], 'sue', function() {}); + expect(len).equal(2); + expect(doc.get(['friends'])).eql(['jim', 'sue']); + }); + it('throws a TypeError when pushing on a non-array', function(done) { + var doc = createDoc(); + doc.set(['friends'], {}, function() {}); + doc.push(['friends'], ['x'], function(err) { + expect(err).instanceOf(TypeError); + done(); + }); + }); + }); + describe('move', function() { + it('can move an item from the end to the beginning of the array', function() { + var doc = createDoc(); + doc.set(['array'], [0, 1, 2, 3, 4], function() {}); + var moved = doc.move(['array'], 4, 0, 1, function() {}); + expect(moved).eql([4]); + expect(doc.get(['array'])).eql([4, 0, 1, 2, 3]); + }); + it('can swap the first two items in the array', function() { + var doc = createDoc(); + doc.set(['array'], [0, 1, 2, 3, 4], function() {}); + var moved = doc.move(['array'], 1, 0, 1, function() {}); + expect(moved).eql([1]); + expect(doc.get(['array'])).eql([1, 0, 2, 3, 4]); + }); + it('can move an item from the begnning to the end of the array', function() { + var doc = createDoc(); + doc.set(['array'], [0, 1, 2, 3, 4], function() {}); + var moved = doc.move(['array'], 0, 4, 1, function() {}); + expect(moved).eql([0]); + expect(doc.get(['array'])).eql([1, 2, 3, 4, 0]); + }); + it('can move several items mid-array, with an event for each', function() { + var doc = createDoc(); + doc.set(['array'], [0, 1, 2, 3, 4], function() {}); + var moved = doc.move(['array'], 1, 3, 2, function() {}); + expect(moved).eql([1, 2]); + expect(doc.get(['array'])).eql([0, 3, 4, 1, 2]); + }); + }); +}; diff --git a/test/Model/events.js b/test/Model/events.js new file mode 100644 index 000000000..e58a19698 --- /dev/null +++ b/test/Model/events.js @@ -0,0 +1,354 @@ +var expect = require('../util').expect; +var racer = require('../../lib/index'); + +describe('Model events without useEventObjects', function() { + describe('mutator events', function() { + it('calls earlier listeners in the order of mutations', function(done) { + var model = (new racer.RootModel()).at('_page'); + var expectedPaths = ['a', 'b', 'c']; + model.on('change', '**', function(path) { + expect(path).to.equal(expectedPaths.shift()); + if (!expectedPaths.length) { + done(); + } + }); + model.on('change', 'a', function() { + model.set('b', 2); + }); + model.on('change', 'b', function() { + model.set('c', 3); + }); + model.set('a', 1); + }); + + it('calls later listeners in the order of mutations', function(done) { + var model = (new racer.RootModel()).at('_page'); + model.on('change', 'a', function() { + model.set('b', 2); + }); + model.on('change', 'b', function() { + model.set('c', 3); + }); + var expectedPaths = ['a', 'b', 'c']; + model.on('change', '**', function(path) { + expect(path).to.equal(expectedPaths.shift()); + if (!expectedPaths.length) { + done(); + } + }); + model.set('a', 1); + }); + + it('can omit the path argument', function(done) { + var model = (new racer.RootModel()).at('_page'); + + model.at('a').on('change', function(value, prev) { + expect(value).to.equal(1); + expect(prev).not.to.exist; + done(); + }); + model.set('a', 1); + }); + }); + + describe('remote events', function() { + beforeEach(function(done) { + var backend = racer.createBackend(); + var local = this.local = backend.createModel().scope('colors.green'); + var remote = this.remote = backend.createModel().scope('colors.green'); + local.create(function(err) { + if (err) return done(err); + remote.subscribe(done); + }); + }); + + describe('set', function() { + it('can raise events registered on array indices', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('change', 'array.0', function(value, previous) { + expect(value).to.equal(1); + expect(previous).to.equal(0); + done(); + }); + this.local.set('array.0', 1); + }); + }); + + describe('move', function() { + it('can move an item from the end to the beginning of the array', function(done) { + this.local.set('array', [0, 1, 2, 3, 4]); + this.remote.on('move', '**', function(captures, from, to) { + expect(from).to.equal(4); + expect(to).to.equal(0); + done(); + }); + this.local.move('array', 4, 0, 1); + }); + it('can swap the first two items in the array', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('move', '**', function(captures, from, to) { + expect(from).to.equal(1); + expect(to).to.equal(0); + done(); + }); + this.local.move('array', 1, 0, 1, function() {}); + }); + it('can move an item from the begnning to the end of the array', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('move', '**', function(captures, from, to) { + expect(from).to.equal(0); + expect(to).to.equal(4); + done(); + }); + this.local.move('array', 0, 4, 1, function() {}); + }); + it('supports a negative destination index of -1 (for last)', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('move', '**', function(captures, from, to) { + expect(from).to.equal(0); + expect(to).to.equal(4); + done(); + }); + this.local.move('array', 0, -1, 1, function() {}); + }); + it('supports a negative source index of -1 (for last)', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('move', '**', function(captures, from, to) { + expect(from).to.equal(4); + expect(to).to.equal(2); + done(); + }); + this.local.move('array', -1, 2, 1, function() {}); + }); + it('can move several items mid-array, with an event for each', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + var events = 0; + this.remote.on('move', '**', function(captures, from, to) { + expect(from).to.equal(1); + expect(to).to.equal(4); + if (++events === 2) { + done(); + } + }); + this.local.move('array', 1, 3, 2, function() {}); + }); + }); + }); +}); + +describe('Model events with {useEventObjects: true}', function() { + describe('mutator events', function() { + it('calls earlier listeners in the order of mutations', function(done) { + var model = (new racer.RootModel()).at('_page'); + var expectedPaths = ['a', 'b', 'c']; + model.on('change', '**', {useEventObjects: true}, function(_event, captures) { + expect(captures).to.eql([expectedPaths.shift()]); + if (!expectedPaths.length) { + done(); + } + }); + model.on('change', 'a', function() { + model.set('b', 2); + }); + model.on('change', 'b', function() { + model.set('c', 3); + }); + model.set('a', 1); + }); + + it('calls later listeners in the order of mutations', function(done) { + var model = (new racer.RootModel()).at('_page'); + model.on('change', 'a', function() { + model.set('b', 2); + }); + model.on('change', 'b', function() { + model.set('c', 3); + }); + var expectedPaths = ['a', 'b', 'c']; + model.on('change', '**', {useEventObjects: true}, function(_event, captures) { + expect(captures).to.eql([expectedPaths.shift()]); + if (!expectedPaths.length) { + done(); + } + }); + model.set('a', 1); + }); + + it('can omit the path argument when useEventObjects is true', function(done) { + var model = (new racer.RootModel()).at('_page'); + + model.at('a').on('change', {useEventObjects: true}, function(_event, captures) { + expect(_event.value).to.equal(1); + expect(captures).to.have.length(0); + done(); + }); + model.set('a', 1); + }); + }); + + describe('insert and remove', function() { + var model; + before('set up', function() { + model = (new racer.RootModel()).at('_page'); + }); + + it('insert has expected properties', function(done) { + model.on('insert', '**', {useEventObjects: true}, function(_event, captures) { + expect(_event.type).to.equal('insert'); + expect(_event.index).to.equal(2); + expect(_event.values).to.eql([3]); + expect(_event.passed).to.eql({}); + expect(captures).to.eql(['a']); + done(); + }); + model.set('a', [1, 2]); + model.insert('a', 2, [3]); + }); + + it('remove has expected properties', function(done) { + model.on('remove', '**', {useEventObjects: true}, function(_event, captures) { + expect(_event.type).to.equal('remove'); + expect(_event.index).to.equal(2); + expect(_event.removed).to.eql([3]); + expect(_event.values).to.eql([3]); + expect(_event.passed).to.eql({}); + expect(captures).to.eql(['a']); + done(); + }); + model.pop('a'); + }); + }); + + describe('load and unload', function() { + var local, remote; + + before('set up', function() { + var backend = racer.createBackend(); + local = backend.createModel().scope('colors.green'); + remote = backend.createModel().scope('colors.green'); + }); + + it('load has expected properties', function(done) { + remote.on('load', '**', {useEventObjects: true}, function(event, captures) { + expect(event.type).to.equal('load'); + expect(event.value).to.eql({id: 'green'}); + expect(event.document).to.eql({id: 'green'}); + expect(event.passed).to.eql({'$remote': true}); + expect(captures).to.eql(['']); + done(); + }); + + local.create(function() { + remote.subscribe(); + }); + }); + + it('unload has expected properties', function(done) { + remote.on('unload', '**', {useEventObjects: true}, function(event, captures) { + expect(event.type).to.equal('unload'); + expect(event.previous).to.eql({id: 'green'}); + expect(event.previousDocument).to.eql({id: 'green'}); + expect(event.passed).to.eql({}); + expect(captures).to.eql(['']); + done(); + }); + remote.unsubscribe(); + }); + }); + + describe('remote events', function() { + beforeEach(function(done) { + var backend = racer.createBackend(); + var local = this.local = backend.createModel().scope('colors.green'); + var remote = this.remote = backend.createModel().scope('colors.green'); + + local.create(function(err) { + if (err) return done(err); + remote.subscribe(done); + }); + }); + + describe('set', function() { + it('can raise events registered on array indices', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('change', 'array.0', {useEventObjects: true}, function(event) { + expect(event.value).to.equal(1); + expect(event.previous).to.equal(0); + done(); + }); + this.local.set('array.0', 1); + }); + }); + + describe('move', function() { + it('can move an item from the end to the beginning of the array', function(done) { + this.local.set('array', [0, 1, 2, 3, 4]); + this.remote.on('move', '**', {useEventObjects: true}, function(event) { + expect(event.from).to.equal(4); + expect(event.to).to.equal(0); + expect(event.howMany).to.equal(1); + done(); + }); + this.local.move('array', 4, 0, 1); + }); + + it('can swap the first two items in the array', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('move', '**', {useEventObjects: true}, function(event) { + expect(event.from).to.equal(1); + expect(event.to).to.equal(0); + expect(event.howMany).to.equal(1); + done(); + }); + this.local.move('array', 1, 0, 1, function() {}); + }); + + it('can move an item from the begnning to the end of the array', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('move', '**', {useEventObjects: true}, function(event) { + expect(event.from).to.equal(0); + expect(event.to).to.equal(4); + expect(event.howMany).to.equal(1); + done(); + }); + this.local.move('array', 0, 4, 1, function() {}); + }); + + it('supports a negative destination index of -1 (for last)', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('move', '**', {useEventObjects: true}, function(event) { + expect(event.from).to.equal(0); + expect(event.to).to.equal(4); + expect(event.howMany).to.equal(1); + done(); + }); + this.local.move('array', 0, -1, 1, function() {}); + }); + + it('supports a negative source index of -1 (for last)', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + this.remote.on('move', '**', {useEventObjects: true}, function(event) { + expect(event.from).to.equal(4); + expect(event.to).to.equal(2); + expect(event.howMany).to.equal(1); + done(); + }); + this.local.move('array', -1, 2, 1, function() {}); + }); + + it('can move several items mid-array, with an event for each', function(done) { + this.local.set('array', [0, 1, 2, 3, 4], function() {}); + var events = 0; + this.remote.on('move', '**', {useEventObjects: true}, function(event) { + expect(event.from).to.equal(1); + expect(event.to).to.equal(4); + expect(event.howMany).to.equal(1); + if (++events === 2) { + done(); + } + }); + this.local.move('array', 1, 3, 2, function() {}); + }); + }); + }); +}); diff --git a/test/Model/events.mocha.coffee b/test/Model/events.mocha.coffee deleted file mode 100644 index b6ec9e190..000000000 --- a/test/Model/events.mocha.coffee +++ /dev/null @@ -1,134 +0,0 @@ -{expect} = require '../util' -Model = require './MockConnectionModel' - -mutationEvents = (createModels) -> - - describe 'set', -> - it 'can raise events registered on array indices', (done) -> - [local, remote] = createModels() - local.set 'array', [0, 1, 2, 3, 4], -> - - remote.on 'change', 'array.0', (value, previous) -> - expect(value).to.equal 1 - expect(previous).to.equal 0 - done() - - local.set 'array.0', 1 - - describe 'move', -> - - it 'can move an item from the end to the beginning of the array', (done) -> - [local, remote] = createModels() - local.set 'array', [0, 1, 2, 3, 4] - - remote.on 'move', '**', (captures..., from, to, howMany, passed) -> - expect(from).to.equal 4 - expect(to).to.equal 0 - done() - - local.move 'array', 4, 0, 1 - - it 'can swap the first two items in the array', (done) -> - [local, remote] = createModels() - local.set 'array', [0, 1, 2, 3, 4], -> - - remote.on 'move', '**', (captures..., from, to, howMany, passed) -> - expect(from).to.equal 1 - expect(to).to.equal 0 - done() - - local.move 'array', 1, 0, 1, -> - - it 'can move an item from the begnning to the end of the array', (done) -> - [local, remote] = createModels() - local.set 'array', [0, 1, 2, 3, 4], -> - - remote.on 'move', '**', (captures..., from, to, howMany, passed) -> - expect(from).to.equal 0 - expect(to).to.equal 4 - done() - - # note that destination is index after removal of item - local.move 'array', 0, 4, 1, -> - - it 'supports a negative destination index of -1 (for last)', (done) -> - [local, remote] = createModels() - local.set 'array', [0, 1, 2, 3, 4], -> - - remote.on 'move', '**', (captures, from, to, howMany, passed) -> - expect(from).to.equal 0 - expect(to).to.equal 4 - done() - - local.move 'array', 0, -1, 1, -> - - it 'supports a negative source index of -1 (for last)', (done) -> - [local, remote] = createModels() - local.set 'array', [0, 1, 2, 3, 4], -> - - remote.on 'move', '**', (captures..., from, to, howMany, passed) -> - expect(from).to.equal 4 - expect(to).to.equal 2 - done() - - local.move 'array', -1, 2, 1, -> - - it 'can move several items mid-array, with an event for each', (done) -> - [local, remote] = createModels() - local.set 'array', [0, 1, 2, 3, 4], -> - - events = 0 - # When going through ShareJS, the single howMany > 1 move is split into - # lots of howMany==1 moves - remote.on 'move', '**', (captures..., from, to, howMany, passed) -> - expect(from).to.equal 1 - expect(to).to.equal 4 - done() if ++events == 2 - - # note that destination is index after removal of items - local.move 'array', 1, 3, 2, -> - -describe 'Model events', -> - - describe 'mutator events', -> - - it 'calls earlier listeners in the order of mutations', (done) -> - model = (new Model).at '_page' - expectedPaths = ['a', 'b', 'c'] - model.on 'change', '**', (path) -> - expect(path).to.equal expectedPaths.shift() - done() unless expectedPaths.length - model.on 'change', 'a', -> - model.set 'b', 2 - model.on 'change', 'b', -> - model.set 'c', 3 - model.set 'a', 1 - - it 'calls later listeners in the order of mutations', (done) -> - model = (new Model).at '_page' - model.on 'change', 'a', -> - model.set 'b', 2 - model.on 'change', 'b', -> - model.set 'c', 3 - expectedPaths = ['a', 'b', 'c'] - model.on 'change', '**', (path) -> - expect(path).to.equal expectedPaths.shift() - done() unless expectedPaths.length - model.set 'a', 1 - - describe 'remote events', -> - createModels = -> - localModel = new Model() - localModel.createConnection() - remoteModel = new Model() - remoteModel.createConnection() - - # Link the two models explicitly, so we can test event content - localDoc = localModel.getOrCreateDoc 'colors', 'green' - remoteDoc = remoteModel.getOrCreateDoc 'colors', 'green' - localDoc.shareDoc.on 'op', (op, isLocal) -> - remoteDoc._onOp op - - return [localModel.scope('colors.green'), remoteModel.scope('colors.green')] - - mutationEvents createModels diff --git a/test/Model/filter.js b/test/Model/filter.js new file mode 100644 index 000000000..0c5672b71 --- /dev/null +++ b/test/Model/filter.js @@ -0,0 +1,240 @@ +var expect = require('../util').expect; +var RootModel = require('../../lib/Model').RootModel; + +describe('filter', function() { + describe('getting', function() { + // this isn't clear as unspported use + it('does not support array', function() { + var model = (new RootModel()).at('_page'); + model.set('numbers', [0, 3, 4, 1, 2, 3, 0]); + var filter = model.filter('numbers', function(number) { + return (number % 2) === 0; + }); + expect(function() { + filter.get(); + }).to.throw(Error); + }); + + it('supports filter of object', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + var filter = model.filter('numbers', function(number) { + return (number % 2) === 0; + }); + expect(filter.get()).to.eql([0, 4, 2, 0]); + }); + + // sort keyword not supported by TS typedef + it('supports sort of object', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + var filter = model.sort('numbers', 'asc'); + expect(filter.get()).to.eql([0, 0, 1, 2, 3, 3, 4]); + filter = model.sort('numbers', 'desc'); + expect(filter.get()).to.eql([4, 3, 3, 2, 1, 0, 0]); + }); + + // magic keyword 'even'? + // not supported by TS typdefs + it('supports filter and sort of object', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + model.fn('even', function(number) { + return (number % 2) === 0; + }); + var filter = model.filter('numbers', 'even').sort(); + expect(filter.get()).to.eql([0, 0, 2, 4]); + }); + + // This case needs to go away + // vargs hard to deduce type + // hard to type callback fn args properly + it('supports additional input paths as var-args', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + model.set('mod', 3); + model.set('offset', 0); + var filter = model.filter('numbers', 'mod', 'offset', function(number, id, numbers, mod, offset) { + return (number % mod) === offset; + }); + expect(filter.get()).to.eql([0, 3, 3, 0]); + }); + + // supported by typescript typedefs as PathLike[] + // although filterfn not typed for vargs handling of this + it('supports additional input paths as array', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + model.set('mod', 3); + model.set('offset', 0); + var filter = model.filter('numbers', ['mod', 'offset'], function(number, id, numbers, mod, offset) { + return (number % mod) === offset; + }); + expect(filter.get()).to.eql([0, 3, 3, 0]); + }); + + it('supports a skip option', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + var options = {skip: 2}; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + model.set('mod', 3); + model.set('offset', 0); + var filter = model.filter('numbers', ['mod', 'offset'], options, function(number, id, numbers, mod, offset) { + return (number % mod) === offset; + }); + expect(filter.get()).to.eql([3, 0]); + }); + }); + + describe('initial value set by ref', function() { + it('supports filter of object', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + var filter = model.filter('numbers', function(number) { + return (number % 2) === 0; + }); + filter.ref('_page.out'); + expect(model.get('out')).to.eql([0, 4, 2, 0]); + }); + + it('supports sort of object', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + var filter = model.sort('numbers', 'asc'); + expect(filter.get()).to.eql([0, 0, 1, 2, 3, 3, 4]); + filter = model.sort('numbers', 'desc'); + filter.ref('_page.out'); + expect(model.get('out')).to.eql([4, 3, 3, 2, 1, 0, 0]); + }); + + it('supports filter and sort of object', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + model.fn('even', function(number) { + return (number % 2) === 0; + }); + var filter = model.filter('numbers', 'even').sort(); + filter.ref('_page.out'); + expect(model.get('out')).to.eql([0, 0, 2, 4]); + }); + }); + + describe('ref updates as items are modified', function() { + it('supports filter of object', function() { + var model = (new RootModel()).at('_page'); + var greenId = model.add('colors', { + name: 'green', + primary: true + }); + model.add('colors', { + name: 'orange', + primary: false + }); + var redId = model.add('colors', { + name: 'red', + primary: true + }); + var filter = model.filter('colors', function(color) { + return color.primary; + }); + filter.ref('_page.out'); + expect(model.get('out')).to.eql([ + { + name: 'green', + primary: true, + id: greenId + }, { + name: 'red', + primary: true, + id: redId + } + ]); + model.set('colors.' + greenId + '.primary', false); + expect(model.get('out')).to.eql([ + { + name: 'red', + primary: true, + id: redId + } + ]); + var yellowId = model.add('colors', { + name: 'yellow', + primary: true + }); + expect(model.get('out')).to.eql([ + { + name: 'red', + primary: true, + id: redId + }, { + name: 'yellow', + primary: true, + id: yellowId + } + ]); + }); + + it('supports additional dynamic inputs as var-args', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + model.set('mod', 3); + model.set('offset', 0); + var filter = model.filter('numbers', 'mod', 'offset', function(number, id, numbers, mod, offset) { + return (number % mod) === offset; + }); + expect(filter.get()).to.eql([0, 3, 3, 0]); + model.set('offset', 1); + expect(filter.get()).to.eql([4, 1]); + model.set('mod', 2); + expect(filter.get()).to.eql([3, 1, 3]); + }); + + it('supports additional dynamic inputs as array', function() { + var model = (new RootModel()).at('_page'); + var numbers = [0, 3, 4, 1, 2, 3, 0]; + for (var i = 0; i < numbers.length; i++) { + model.set('numbers.' + model.id(), numbers[i]); + } + model.set('mod', 3); + model.set('offset', 0); + var filter = model.filter('numbers', ['mod', 'offset'], function(number, id, numbers, mod, offset) { + return (number % mod) === offset; + }); + expect(filter.get()).to.eql([0, 3, 3, 0]); + model.set('offset', 1); + expect(filter.get()).to.eql([4, 1]); + model.set('mod', 2); + expect(filter.get()).to.eql([3, 1, 3]); + }); + }); +}); diff --git a/test/Model/filter.mocha.coffee b/test/Model/filter.mocha.coffee deleted file mode 100644 index a0d4f47de..000000000 --- a/test/Model/filter.mocha.coffee +++ /dev/null @@ -1,159 +0,0 @@ -{expect} = require '../util' -Model = require '../../lib/Model' - -describe 'filter', -> - - describe 'getting', -> - - it 'supports filter of array', -> - model = (new Model).at '_page' - model.set 'numbers', [0, 3, 4, 1, 2, 3, 0] - filter = model.filter 'numbers', (number, i, numbers) -> - return (number % 2) == 0 - expect(filter.get()).to.eql [0, 4, 2, 0] - - it 'supports sort of array', -> - model = (new Model).at '_page' - model.set 'numbers', [0, 3, 4, 1, 2, 3, 0] - filter = model.sort 'numbers', 'asc' - expect(filter.get()).to.eql [0, 0, 1, 2, 3, 3, 4] - filter = model.sort 'numbers', 'desc' - expect(filter.get()).to.eql [4, 3, 3, 2, 1, 0, 0] - - it 'supports filter and sort of array', -> - model = (new Model).at '_page' - model.set 'numbers', [0, 3, 4, 1, 2, 3, 0] - model.fn 'even', (number) -> - return (number % 2) == 0 - filter = model.filter('numbers', 'even').sort() - expect(filter.get()).to.eql [0, 0, 2, 4] - - it 'supports filter of object', -> - model = (new Model).at '_page' - for number in [0, 3, 4, 1, 2, 3, 0] - model.set 'numbers.' + model.id(), number - filter = model.filter 'numbers', (number, id, numbers) -> - return (number % 2) == 0 - expect(filter.get()).to.eql [0, 4, 2, 0] - - it 'supports sort of object', -> - model = (new Model).at '_page' - for number in [0, 3, 4, 1, 2, 3, 0] - model.set 'numbers.' + model.id(), number - filter = model.sort 'numbers', 'asc' - expect(filter.get()).to.eql [0, 0, 1, 2, 3, 3, 4] - filter = model.sort 'numbers', 'desc' - expect(filter.get()).to.eql [4, 3, 3, 2, 1, 0, 0] - - it 'supports filter and sort of object', -> - model = (new Model).at '_page' - for number in [0, 3, 4, 1, 2, 3, 0] - model.set 'numbers.' + model.id(), number - model.fn 'even', (number) -> - return (number % 2) == 0 - filter = model.filter('numbers', 'even').sort() - expect(filter.get()).to.eql [0, 0, 2, 4] - - describe 'initial value set by ref', -> - - it 'supports filter of array', -> - model = (new Model).at '_page' - model.set 'numbers', [0, 3, 4, 1, 2, 3, 0] - filter = model.filter 'numbers', (number) -> - return (number % 2) == 0 - filter.ref '_page.out' - expect(model.get 'out').to.eql [0, 4, 2, 0] - - it 'supports sort of array', -> - model = (new Model).at '_page' - model.set 'numbers', [0, 3, 4, 1, 2, 3, 0] - filter = model.sort 'numbers', 'asc' - expect(filter.get()).to.eql [0, 0, 1, 2, 3, 3, 4] - filter = model.sort 'numbers', 'desc' - filter.ref '_page.out' - expect(model.get 'out').to.eql [4, 3, 3, 2, 1, 0, 0] - - it 'supports filter and sort of array', -> - model = (new Model).at '_page' - model.set 'numbers', [0, 3, 4, 1, 2, 3, 0] - model.fn 'even', (number) -> - return (number % 2) == 0 - filter = model.filter('numbers', 'even').sort() - filter.ref '_page.out' - expect(model.get 'out').to.eql [0, 0, 2, 4] - - it 'supports filter of object', -> - model = (new Model).at '_page' - for number in [0, 3, 4, 1, 2, 3, 0] - model.set 'numbers.' + model.id(), number - filter = model.filter 'numbers', (number) -> - return (number % 2) == 0 - filter.ref '_page.out' - expect(model.get 'out').to.eql [0, 4, 2, 0] - - it 'supports sort of object', -> - model = (new Model).at '_page' - for number in [0, 3, 4, 1, 2, 3, 0] - model.set 'numbers.' + model.id(), number - filter = model.sort 'numbers', 'asc' - expect(filter.get()).to.eql [0, 0, 1, 2, 3, 3, 4] - filter = model.sort 'numbers', 'desc' - filter.ref '_page.out' - expect(model.get 'out').to.eql [4, 3, 3, 2, 1, 0, 0] - - it 'supports filter and sort of object', -> - model = (new Model).at '_page' - for number in [0, 3, 4, 1, 2, 3, 0] - model.set 'numbers.' + model.id(), number - model.fn 'even', (number) -> - return (number % 2) == 0 - filter = model.filter('numbers', 'even').sort() - filter.ref '_page.out' - expect(model.get 'out').to.eql [0, 0, 2, 4] - - describe 'ref updates as items are modified', -> - - it 'supports filter of array', -> - model = (new Model).at '_page' - model.set 'numbers', [0, 3, 4, 1, 2, 3, 0] - filter = model.filter 'numbers', (number) -> - return (number % 2) == 0 - filter.ref '_page.out' - expect(model.get 'out').to.eql [0, 4, 2, 0] - model.push 'numbers', 6 - expect(model.get 'out').to.eql [0, 4, 2, 0, 6] - model.set 'numbers.2', 1 - expect(model.get 'out').to.eql [0, 2, 0, 6] - model.del 'numbers' - expect(model.get 'out').to.eql [] - model.set 'numbers', [1, 2, 0] - expect(model.get 'out').to.eql [2, 0] - - it 'supports filter of object', -> - model = (new Model).at '_page' - greenId = model.add 'colors', - name: 'green' - primary: true - orangeId = model.add 'colors', - name: 'orange' - primary: false - redId = model.add 'colors', - name: 'red' - primary: true - filter = model.filter 'colors', (color) -> color.primary - filter.ref '_page.out' - expect(model.get 'out').to.eql [ - {name: 'green', primary: true, id: greenId} - {name: 'red', primary: true, id: redId} - ] - model.set 'colors.' + greenId + '.primary', false - expect(model.get 'out').to.eql [ - {name: 'red', primary: true, id: redId} - ] - yellowId = model.add 'colors', - name: 'yellow' - primary: true - expect(model.get 'out').to.eql [ - {name: 'red', primary: true, id: redId} - {name: 'yellow', primary: true, id: yellowId} - ] diff --git a/test/Model/fn.js b/test/Model/fn.js new file mode 100644 index 000000000..5e53bad41 --- /dev/null +++ b/test/Model/fn.js @@ -0,0 +1,492 @@ +var expect = require('../util').expect; +var RootModel = require('../../lib/Model').RootModel; + +describe('fn', function() { + describe('evaluate', function() { + it('supports fn with a getter function', function() { + var model = new RootModel(); + model.fn('sum', function(a, b) { + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + var result = model.evaluate('_nums.a', '_nums.b', 'sum'); + expect(result).to.equal(6); + }); + it('supports fn with an object', function() { + var model = new RootModel(); + model.fn('sum', { + get: function(a, b) { + return a + b; + } + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + var result = model.evaluate('_nums.a', '_nums.b', 'sum'); + expect(result).to.equal(6); + }); + it('supports fn with variable arguments', function() { + var model = new RootModel(); + model.fn('sum', function() { + var sum = 0; + var i = arguments.length; + while (i--) { + sum += arguments[i]; + } + return sum; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.set('_nums.c', 7); + var result = model.evaluate('_nums.a', '_nums.b', '_nums.c', 'sum'); + expect(result).to.equal(13); + }); + it('supports scoped model paths', function() { + var model = new RootModel(); + model.fn('sum', function(a, b) { + return a + b; + }); + var $nums = model.at('_nums'); + $nums.set('a', 2); + $nums.set('b', 4); + var result = model.evaluate('_nums.a', '_nums.b', 'sum'); + expect(result).to.equal(6); + result = $nums.evaluate('a', 'b', 'sum'); + expect(result).to.equal(6); + }); + }); + describe('start', function() { + it('sets the output immediately on start', function() { + var model = new RootModel(); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + var value = model.start('_nums.sum', '_nums.a', '_nums.b', function(a, b) { + return a + b; + }); + expect(value).to.equal(6); + expect(model.get('_nums.sum')).to.equal(6); + }); + it('supports function name argument', function() { + var model = new RootModel(); + model.fn('sum', function(a, b) { + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + var value = model.start('_nums.sum', '_nums.a', '_nums.b', 'sum'); + expect(value).to.equal(6); + expect(model.get('_nums.sum')).to.equal(6); + }); + it('sets the output when an input changes', function() { + var model = new RootModel(); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.start('_nums.sum', '_nums.a', '_nums.b', function(a, b) { + return a + b; + }); + expect(model.get('_nums.sum')).to.equal(6); + model.set('_nums.a', 5); + expect(model.get('_nums.sum')).to.equal(9); + }); + it('sets the output when a parent of the input changes', function() { + var model = new RootModel(); + model.set('_nums.in', { + a: 2, + b: 4 + }); + model.start('_nums.sum', '_nums.in.a', '_nums.in.b', function(a, b) { + return a + b; + }); + expect(model.get('_nums.sum')).to.equal(6); + model.set('_nums.in', { + a: 5, + b: 7 + }); + expect(model.get('_nums.sum')).to.equal(12); + }); + it('does not set the output when a sibling of the input changes', function() { + var model = new RootModel(); + model.set('_nums.in', { + a: 2, + b: 4 + }); + var count = 0; + model.start('_nums.sum', '_nums.in.a', '_nums.in.b', function(a, b) { + count++; + return a + b; + }); + expect(model.get('_nums.sum')).to.equal(6); + expect(count).to.equal(1); + model.set('_nums.in.a', 3); + expect(model.get('_nums.sum')).to.equal(7); + expect(count).to.equal(2); + model.set('_nums.in.c', -1); + expect(model.get('_nums.sum')).to.equal(7); + expect(count).to.equal(2); + }); + it('calling twice cleans up listeners for former function', function() { + var model = new RootModel(); + model.set('_nums.in', { + a: 2, + b: 4 + }); + var count = 0; + model.start('_nums.sum', '_nums.in.a', '_nums.in.b', function(a, b) { + count++; + return a + b; + }); + expect(model.get('_nums.sum')).to.equal(6); + expect(count).to.equal(1); + model.start('_nums.sum', '_nums.in.a', '_nums.in.b', function(a, b) { + return a + b; + }); + expect(model.get('_nums.sum')).to.equal(6); + expect(count).to.equal(1); + model.set('_nums.in.a', 3); + expect(model.get('_nums.sum')).to.equal(7); + expect(count).to.equal(1); + }); + }); + + describe('stop', function() { + it('can call stop without start', function() { + var model = new RootModel(); + model.stop('_nums.sum'); + }); + it('stops updating after calling stop', function() { + var model = new RootModel(); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.start('_nums.sum', '_nums.a', '_nums.b', function(a, b) { + return a + b; + }); + model.set('_nums.a', 1); + expect(model.get('_nums.sum')).to.equal(5); + model.stop('_nums.sum'); + model.set('_nums.a', 3); + expect(model.get('_nums.sum')).to.equal(5); + }); + it('stops updating when start was called twice', function() { + var model = new RootModel(); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.start('_nums.sum', '_nums.a', '_nums.b', function(a, b) { + return a + b; + }); + model.start('_nums.sum', '_nums.a', '_nums.b', function(a, b) { + return a + b; + }); + model.set('_nums.a', 1); + expect(model.get('_nums.sum')).to.equal(5); + model.stop('_nums.sum'); + model.set('_nums.a', 3); + expect(model.get('_nums.sum')).to.equal(5); + }); + }); + describe('stopAll', function() { + it('can call without start', function() { + var model = new RootModel(); + model.stopAll('_nums.sum'); + }); + it('stops updating functions at matching paths', function() { + var model = new RootModel(); + model.fn('sum', function(a, b) { + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.set('_nums.c', 3); + model.set('_nums.d', 7); + model.start('_continue.x', '_nums.a', '_nums.b', 'sum'); + model.start('_continue.y', '_nums.c', '_nums.d', 'sum'); + model.start('_halt.x', '_nums.a', '_nums.b', 'sum'); + model.start('_halt.y', '_nums.c', '_nums.d', 'sum'); + model.set('_nums.a', 1); + model.set('_nums.c', 10); + expect(model.get('_continue')).eql({x: 5, y: 17}); + expect(model.get('_halt')).eql({x: 5, y: 17}); + model.stopAll('_halt'); + model.set('_nums.a', 0); + model.set('_nums.c', 0); + expect(model.get('_continue')).eql({x: 4, y: 7}); + expect(model.get('_halt')).eql({x: 5, y: 17}); + }); + }); + describe('start with array inputs', function() { + it('array inputs and function name', function() { + var model = new RootModel(); + model.fn('sum', function(a, b) { + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + var value = model.start('_nums.sum', ['_nums.a', '_nums.b'], 'sum'); + expect(value).to.equal(6); + expect(model.get('_nums.sum')).to.equal(6); + }); + it('array inputs and function argument', function() { + var model = new RootModel(); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + var value = model.start('_nums.sum', ['_nums.a', '_nums.b'], function(a, b) { + return a + b; + }); + expect(value).to.equal(6); + expect(model.get('_nums.sum')).to.equal(6); + }); + }); + describe('start with async option', function() { + it('sets the output immediately on start', function() { + var model = new RootModel(); + model.fn('sum', function(a, b) { + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + var value = model.start('_nums.sum', '_nums.a', '_nums.b', {async: true}, 'sum'); + expect(value).to.equal(6); + expect(model.get('_nums.sum')).to.equal(6); + }); + it('async sets the output when an input changes', function(done) { + var model = new RootModel(); + model.fn('sum', function(a, b) { + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.start('_nums.sum', '_nums.a', '_nums.b', {async: true}, 'sum'); + expect(model.get('_nums.sum')).to.equal(6); + model.set('_nums.a', 5); + // Synchronously, there should be no change + expect(model.get('_nums.sum')).to.equal(6); + // Async, the value should be updated + process.nextTick(function() { + expect(model.get('_nums.sum')).to.equal(9); + done(); + }); + }); + it('debouncing gets reset', function(done) { + var model = new RootModel(); + model.fn('sum', function(a, b) { + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.start('_nums.sum', '_nums.a', '_nums.b', {async: true}, 'sum'); + expect(model.get('_nums.sum')).to.equal(6); + model.set('_nums.a', 5); + // Synchronously, there should be no change + expect(model.get('_nums.sum')).to.equal(6); + // Async, the value should be updated + process.nextTick(function() { + expect(model.get('_nums.sum')).to.equal(9); + model.set('_nums.b', 0); + expect(model.get('_nums.sum')).to.equal(9); + process.nextTick(function() { + expect(model.get('_nums.sum')).to.equal(5); + done(); + }); + }); + }); + it('no async sets the output multiple times when an input changes multiple times', function() { + var model = new RootModel(); + var calls = 0; + model.fn('sum', function(a, b) { + calls++; + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.start('_nums.sum', '_nums.a', '_nums.b', 'sum'); + expect(model.get('_nums.sum')).to.equal(6); + // Synchronously, the value should change + model.set('_nums.a', 1); + expect(model.get('_nums.sum')).to.equal(5); + model.set('_nums.a', 5); + expect(model.get('_nums.sum')).to.equal(9); + model.set('_nums.b', 10); + expect(model.get('_nums.sum')).to.equal(15); + model.set('_nums.b', 4); + expect(model.get('_nums.sum')).to.equal(9); + expect(calls).to.equal(5); + }); + it('async sets the output when an input changes multiple times', function(done) { + var model = new RootModel(); + var calls = 0; + model.fn('sum', function(a, b) { + calls++; + return a + b; + }); + model.set('_nums.a', 2); + model.set('_nums.b', 4); + model.start('_nums.sum', '_nums.a', '_nums.b', {async: true}, 'sum'); + expect(model.get('_nums.sum')).to.equal(6); + // Synchronously, there should be no change + model.set('_nums.a', 1); + expect(model.get('_nums.sum')).to.equal(6); + model.set('_nums.a', 5); + expect(model.get('_nums.sum')).to.equal(6); + model.set('_nums.b', 10); + expect(model.get('_nums.sum')).to.equal(6); + model.set('_nums.b', 4); + expect(model.get('_nums.sum')).to.equal(6); + // Async, the value should be updated once + process.nextTick(function() { + expect(model.get('_nums.sum')).to.equal(9); + expect(calls).to.equal(2); + done(); + }); + }); + }); + describe('setter', function() { + it('sets the input when the output changes', function() { + var model = new RootModel(); + model.fn('fullName', { + get: function(first, last) { + return first + ' ' + last; + }, + set: function(fullName) { + return fullName.split(' '); + } + }); + model.set('_user.name', { + first: 'John', + last: 'Smith' + }); + model.at('_user.name').start('full', 'first', 'last', 'fullName'); + expect(model.get('_user.name')).to.eql({ + first: 'John', + last: 'Smith', + full: 'John Smith' + }); + model.set('_user.name.full', 'Jane Doe'); + expect(model.get('_user.name')).to.eql({ + first: 'Jane', + last: 'Doe', + full: 'Jane Doe' + }); + }); + }); + describe('event mirroring', function() { + it('emits move event on output when input changes', function(done) { + var model = new RootModel(); + model.fn('unity', { + get: function(value) { + return value; + }, + set: function(value) { + return [value]; + } + }); + model.set('_test.in', { + a: [ + { + x: 1, + y: 2 + }, { + x: 2, + y: 0 + } + ] + }); + model.start('_test.out', '_test.in', 'unity'); + model.on('all', '_test.out.**', function(path, event) { + expect(event).to.equal('move'); + expect(path).to.equal('a'); + done(); + }); + model.move('_test.in.a', 0, 1); + expect(model.get('_test.out')).to.eql(model.get('_test.in')); + }); + it('emits move event on input when output changes', function(done) { + var model = new RootModel(); + model.fn('unity', { + get: function(value) { + return value; + }, + set: function(value) { + return [value]; + } + }); + model.set('_test.in', { + a: [ + { + x: 1, + y: 2 + }, { + x: 2, + y: 0 + } + ] + }); + model.start('_test.out', '_test.in', 'unity'); + model.on('all', '_test.in.**', function(path, event) { + expect(event).to.equal('move'); + expect(path).to.equal('a'); + done(); + }); + model.move('_test.out.a', 0, 1); + expect(model.get('_test.out')).to.eql(model.get('_test.in')); + }); + it('emits granular change event under an array when input changes', function(done) { + var model = new RootModel(); + model.fn('unity', { + get: function(value) { + return value; + }, + set: function(value) { + return [value]; + } + }); + model.set('_test.in', { + a: [ + { + x: 1, + y: 2 + }, { + x: 2, + y: 0 + } + ] + }); + model.start('_test.out', '_test.in', 'unity'); + model.on('all', '_test.out.**', function(path, event) { + expect(event).to.equal('change'); + expect(path).to.equal('a.0.x'); + done(); + }); + model.set('_test.in.a.0.x', 3); + expect(model.get('_test.out')).to.eql(model.get('_test.in')); + }); + it('emits granular change event under an array when output changes', function(done) { + var model = new RootModel(); + model.fn('unity', { + get: function(value) { + return value; + }, + set: function(value) { + return [value]; + } + }); + model.set('_test.in', { + a: [ + { + x: 1, + y: 2 + }, { + x: 2, + y: 0 + } + ] + }); + model.start('_test.out', '_test.in', 'unity'); + model.on('all', '_test.in.**', function(path, event) { + expect(event).to.equal('change'); + expect(path).to.equal('a.0.x'); + done(); + }); + model.set('_test.out.a.0.x', 3); + expect(model.get('_test.out')).to.eql(model.get('_test.in')); + }); + }); +}); diff --git a/test/Model/fn.mocha.coffee b/test/Model/fn.mocha.coffee deleted file mode 100644 index 984f53c4e..000000000 --- a/test/Model/fn.mocha.coffee +++ /dev/null @@ -1,202 +0,0 @@ -{expect} = require '../util' -Model = require '../../lib/Model' - -describe 'fn', -> - - describe 'evaluate', -> - - it 'supports fn with a getter function', -> - model = new Model - model.fn 'sum', (a, b) -> a + b - model.set '_nums.a', 2 - model.set '_nums.b', 4 - result = model.evaluate '_nums.a', '_nums.b', 'sum' - expect(result).to.equal 6 - - it 'supports fn with an object', -> - model = new Model - model.fn 'sum', - get: (a, b) -> a + b - model.set '_nums.a', 2 - model.set '_nums.b', 4 - result = model.evaluate '_nums.a', '_nums.b', 'sum' - expect(result).to.equal 6 - - it 'supports fn with variable arguments', -> - model = new Model - model.fn 'sum', (args...) -> - sum = 0 - sum += arg for arg in args - return sum - model.set '_nums.a', 2 - model.set '_nums.b', 4 - model.set '_nums.c', 7 - result = model.evaluate '_nums.a', '_nums.b', '_nums.c', 'sum' - expect(result).to.equal 13 - - it 'supports scoped model paths', -> - model = new Model - model.fn 'sum', (a, b) -> a + b - $nums = model.at '_nums' - $nums.set 'a', 2 - $nums.set 'b', 4 - result = model.evaluate '_nums.a', '_nums.b', 'sum' - expect(result).to.equal 6 - result = $nums.evaluate 'a', 'b', 'sum' - expect(result).to.equal 6 - - describe 'start and stop with getter', -> - - it 'sets the output immediately on start', -> - model = new Model - model.fn 'sum', (a, b) -> a + b - model.set '_nums.a', 2 - model.set '_nums.b', 4 - value = model.start '_nums.sum', '_nums.a', '_nums.b', 'sum' - expect(value).to.equal 6 - expect(model.get '_nums.sum').to.equal 6 - - it 'sets the output when an input changes', -> - model = new Model - model.fn 'sum', (a, b) -> a + b - model.set '_nums.a', 2 - model.set '_nums.b', 4 - model.start '_nums.sum', '_nums.a', '_nums.b', 'sum' - expect(model.get '_nums.sum').to.equal 6 - model.set '_nums.a', 5 - expect(model.get '_nums.sum').to.equal 9 - - it 'sets the output when a parent of the input changes', -> - model = new Model - model.fn 'sum', (a, b) -> a + b - model.set '_nums.in', {a: 2, b: 4} - model.start '_nums.sum', '_nums.in.a', '_nums.in.b', 'sum' - expect(model.get '_nums.sum').to.equal 6 - model.set '_nums.in', {a: 5, b: 7} - expect(model.get '_nums.sum').to.equal 12 - - it 'does not set the output when a sibling of the input changes', -> - model = new Model - count = 0 - model.fn 'sum', (a, b) -> count++; a + b - model.set '_nums.in', {a: 2, b: 4} - model.start '_nums.sum', '_nums.in.a', '_nums.in.b', 'sum' - expect(model.get '_nums.sum').to.equal 6 - expect(count).to.equal 1 - model.set '_nums.in.a', 3 - expect(model.get '_nums.sum').to.equal 7 - expect(count).to.equal 2 - model.set '_nums.in.c', -1 - expect(model.get '_nums.sum').to.equal 7 - expect(count).to.equal 2 - - it 'can call stop without start', -> - model = new Model - model.stop '_nums.sum' - - it 'stops updating after calling stop', -> - model = new Model - model.fn 'sum', (a, b) -> a + b - model.set '_nums.a', 2 - model.set '_nums.b', 4 - model.start '_nums.sum', '_nums.a', '_nums.b', 'sum' - model.set '_nums.a', 1 - expect(model.get '_nums.sum').to.equal 5 - model.stop '_nums.sum' - model.set '_nums.a', 3 - expect(model.get '_nums.sum').to.equal 5 - - describe 'setter', -> - - it 'sets the input when the output changes', -> - model = new Model - model.fn 'fullName', - get: (first, last) -> first + ' ' + last - set: (fullName) -> fullName.split ' ' - model.set '_user.name', - first: 'John' - last: 'Smith' - model.at('_user.name').start 'full', 'first', 'last', 'fullName' - expect(model.get '_user.name').to.eql - first: 'John' - last: 'Smith' - full: 'John Smith' - model.set '_user.name.full', 'Jane Doe' - expect(model.get '_user.name').to.eql - first: 'Jane' - last: 'Doe' - full: 'Jane Doe' - - describe 'event mirroring', -> - - it 'emits move event on output when input changes', (done) -> - model = new Model - model.fn 'unity', - get: (value) -> value - set: (value) -> [value] - model.set '_test.in', - a: [ - {x: 1, y: 2} - {x: 2, y: 0} - ] - model.start '_test.out', '_test.in', 'unity' - model.on 'all', '_test.out**', (path, event) -> - expect(event).to.equal 'move' - expect(path).to.equal 'a' - done() - model.move '_test.in.a', 0, 1 - expect(model.get '_test.out').to.eql model.get('_test.in') - - it 'emits move event on input when output changes', (done) -> - model = new Model - model.fn 'unity', - get: (value) -> value - set: (value) -> [value] - model.set '_test.in', - a: [ - {x: 1, y: 2} - {x: 2, y: 0} - ] - model.start '_test.out', '_test.in', 'unity' - model.on 'all', '_test.in**', (path, event) -> - expect(event).to.equal 'move' - expect(path).to.equal 'a' - done() - model.move '_test.out.a', 0, 1 - expect(model.get '_test.out').to.eql model.get('_test.in') - - it 'emits granular change event under an array when input changes', (done) -> - model = new Model - model.fn 'unity', - get: (value) -> value - set: (value) -> [value] - model.set '_test.in', - a: [ - {x: 1, y: 2} - {x: 2, y: 0} - ] - model.start '_test.out', '_test.in', 'unity' - model.on 'all', '_test.out**', (path, event) -> - expect(event).to.equal 'change' - expect(path).to.equal 'a.0.x' - done() - model.set '_test.in.a.0.x', 3 - expect(model.get '_test.out').to.eql model.get('_test.in') - - it 'emits granular change event under an array when output changes', (done) -> - model = new Model - model.fn 'unity', - get: (value) -> value - set: (value) -> [value] - model.set '_test.in', - a: [ - {x: 1, y: 2} - {x: 2, y: 0} - ] - model.start '_test.out', '_test.in', 'unity' - model.on 'all', '_test.in**', (path, event) -> - expect(event).to.equal 'change' - expect(path).to.equal 'a.0.x' - done() - model.set '_test.out.a.0.x', 3 - expect(model.get '_test.out').to.eql model.get('_test.in') diff --git a/test/Model/loading.js b/test/Model/loading.js new file mode 100644 index 000000000..82d2e6dc6 --- /dev/null +++ b/test/Model/loading.js @@ -0,0 +1,211 @@ +var expect = require('../util').expect; +var racer = require('../../lib/index'); + +describe('loading', function() { + beforeEach(function(done) { + this.backend = racer.createBackend(); + // Add a delay on all messages to help catch race issues + var delay = 5; + this.backend.use('receive', function(request, next) { + delay++; + setTimeout(next, delay); + }); + this.model = this.backend.createModel(); + this.model.connection.on('connected', done); + }); + + describe('fetch', function() { + beforeEach(function(done) { + this.setupModel = this.backend.createModel(); + this.setupModel.add('foo', {id: '1', name: 'foo-1'}, done); + }); + + it('calls callback after fetch completes', function(done) { + var model = this.model; + model.fetch('foo.1', function(err) { + if (err) done(err); + var doc = model.get('foo.1'); + expect(doc).to.have.property('id', '1'); + expect(doc).to.have.property('name', 'foo-1'); + done(); + }); + }); + }); + + describe('fetchPromised', function() { + beforeEach(function(done) { + this.setupModel = this.backend.createModel(); + this.setupModel.add('foo', {id: '2', name: 'foo-2'}, done); + }); + + it('resolves promise when fetch completes', async function() { + var model = this.model; + await model.fetchPromised('foo.2'); + var doc = model.get('foo.2'); + expect(doc).to.have.property('id', '2'); + expect(doc).to.have.property('name', 'foo-2'); + }); + }); + + describe('subscribe', function() { + it('calls back simultaneous subscribes to the same document', function(done) { + var doc = this.model.connection.get('colors', 'green'); + expect(doc.version).equal(null); + + var calls = 0; + var cb = function(err) { + if (err) return done(err); + expect(doc.version).equal(0); + calls++; + }; + for (var i = 3; i--;) { + this.model.subscribe('colors.green', cb); + } + + this.model.whenNothingPending(function() { + expect(calls).equal(3); + done(); + }); + }); + + it('calls back when doc is already subscribed', function(done) { + var model = this.model; + var doc = model.connection.get('colors', 'green'); + model.subscribe('colors.green', function(err) { + if (err) return done(err); + expect(doc.subscribed).equal(true); + model.subscribe('colors.green', done); + }); + }); + }); + + describe('subscribePromised', function() { + it('resolves promise when subscribe complete', async function() { + var model = this.model; + var doc = model.connection.get('colors', 'green'); + expect(doc.subscribed).equal(false); + await model.subscribePromised('colors.green'); + expect(doc.subscribed).equal(true); + }); + }); + + describe('unfetch deferred unload', function() { + beforeEach(function(done) { + this.setupModel = this.backend.createModel(); + this.setupModel.add('colors', {id: 'green', hex: '00ff00'}, done); + }); + + it('unloads doc after Share doc has nothing pending', function(done) { + var model = this.model; + model.fetch('colors.green', function(err) { + if (err) return done(err); + expect(model.get('colors.green.hex')).to.equal('00ff00'); + // Queue up a pending op. + model.set('colors.green.hex', '00ee00'); + // Unfetch. This triggers the delayed _maybeUnloadDoc. + // The pending op causes the doc unload to be delayed. + model.unfetch('colors.green'); + // Once there's nothing pending on the model/doc... + model.whenNothingPending(function() { + // Racer doc should be unloaded. + expect(model.get('colors.green')).to.equal(undefined); + // Share doc should be unloaded too. + expect(model.connection.getExisting('colors', 'green')).to.equal(undefined); + done(); + }); + }); + }); + + it('does not unload doc if a subscribe is issued in the meantime', function(done) { + var model = this.model; + // Racer keeps its own reference counts of doc fetches/subscribes - see `_hasDocReferences`. + model.fetch('colors.green', function(err) { + if (err) return done(err); + expect(model.get('colors.green.hex')).to.equal('00ff00'); + // Queue up a pending op. + model.set('colors.green.hex', '00ee00'); + // Unfetch. This triggers the delayed _maybeUnloadDoc. + // The pending op causes the doc unload to be delayed. + model.unfetch('colors.green'); + // Immediately subscribe to the same doc. + // This causes the doc to be kept in memory, even after the unfetch completes. + model.subscribe('colors.green'); + // Once there's nothing pending on the model/doc... + model.whenNothingPending(function() { + // Racer doc should still be present due to the subscription. + expect(model.get('colors.green')).to.eql({id: 'green', hex: '00ee00'}); + // Share doc should be present too. + var shareDoc = model.connection.getExisting('colors', 'green'); + expect(shareDoc).to.have.property('data'); + expect(shareDoc.data).to.eql({id: 'green', hex: '00ee00'}); + done(); + }); + }); + }); + }); + + describe('when using model.unload()', function() { + beforeEach(function(done) { + this.setupModel = this.backend.createModel(); + this.setupModel.add('colors', {id: 'green', hex: '00ff00'}); + this.setupModel.add('colors', {id: 'blue', hex: '0000ff'}); + this.setupModel.whenNothingPending(done); + }); + + it('unloads all documents after model has nothing pending', function(done) { + var model = this.model; + model.fetch('colors.green', 'colors.blue', function(err) { + if (err) return done(err); + expect(model.get('colors.green.hex')).to.equal('00ff00'); + expect(model.get('colors.blue.hex')).to.equal('0000ff'); + // Queue up a pending op. + model.set('colors.green.hex', '00ee00'); + // Unfetch. This triggers the delayed _maybeUnloadDoc. + // The pending op causes the doc unload to be delayed. + model.unload(); + // Once there's nothing pending on the model/doc... + model.whenNothingPending(function() { + // Racer doc should be unloaded. + expect(model.get('colors.green')).to.equal(undefined); + expect(model.get('colors.blue')).to.equal(undefined); + // Share doc should be unloaded too. + expect(model.connection.getExisting('colors', 'green')).to.equal(undefined); + expect(model.connection.getExisting('colors', 'blue')).to.equal(undefined); + done(); + }); + }); + }); + + it('only unloads documents without a pending operation or subscription', function(done) { + var model = this.model; + // Racer keeps its own reference counts of doc fetches/subscribes - see `_hasDocReferences`. + model.fetch('colors.green', 'colors.blue', function(err) { + if (err) return done(err); + expect(model.get('colors.green.hex')).to.equal('00ff00'); + expect(model.get('colors.blue.hex')).to.equal('0000ff'); + // Queue up a pending op. + model.set('colors.green.hex', '00ee00'); + // Unfetch. This triggers the delayed _maybeUnloadDoc. + // The pending op causes the doc unload to be delayed. + model.unload(); + // Immediately subscribe to the same doc. + // This causes the doc to be kept in memory, even after the unfetch completes. + model.subscribe('colors.green'); + // Once there's nothing pending on the model/doc... + model.whenNothingPending(function() { + // Racer doc should still be present due to the subscription. + expect(model.get('colors.green')).to.eql({id: 'green', hex: '00ee00'}); + // Share doc should be present too. + var shareDoc = model.connection.getExisting('colors', 'green'); + expect(shareDoc).to.have.property('data'); + expect(shareDoc.data).to.eql({id: 'green', hex: '00ee00'}); + // Racer doc for blue should be unloaded + expect(model.get('colors.blue')).to.equal(undefined); + // Share doc for blue should be unloaded too + expect(model.connection.getExisting('colors', 'blue')).to.equal(undefined); + done(); + }); + }); + }); + }); +}); diff --git a/test/Model/mutators.js b/test/Model/mutators.js new file mode 100644 index 000000000..589ba3d93 --- /dev/null +++ b/test/Model/mutators.js @@ -0,0 +1,23 @@ +const {expect} = require('chai'); +const {RootModel} = require('../../lib/Model'); + +describe('mutators', () => { + describe('add', () => { + const guidRegExp = new RegExp(/[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}/); + it('returns created id in callback', () => { + const model = new RootModel(); + model.add('_test_doc', {name: 'foo'}, (error, id) => { + expect(error).to.not.exist; + expect(id).not.to.be.undefined; + expect(id).to.match(guidRegExp, 'Expected a GUID-like Id'); + }); + }); + + it('resolves promised add with id', async () => { + const model = new RootModel(); + const id = await model.addPromised('_test_doc', {name: 'bar'}); + expect(id).not.to.be.undefined; + expect(id).to.match(guidRegExp, 'Expected a GUID-like Id'); + }); + }); +}); diff --git a/test/Model/path.js b/test/Model/path.js new file mode 100644 index 000000000..730ba36ff --- /dev/null +++ b/test/Model/path.js @@ -0,0 +1,63 @@ +var expect = require('../util').expect; +var RootModel = require('../../lib/Model').RootModel; + +describe('path methods', function() { + describe('path', function() { + it('returns empty string for model without scope', function() { + var model = new RootModel(); + expect(model.path()).equal(''); + }); + }); + describe('scope', function() { + it('returns a child model with the absolute scope', function() { + var model = new RootModel(); + var scoped = model.scope('foo.bar.baz'); + expect(model.path()).equal(''); + expect(scoped.path()).equal('foo.bar.baz'); + }); + it('supports segments as separate arguments', function() { + var model = new RootModel(); + var scoped = model.scope('foo', 'bar', 'baz'); + expect(model.path()).equal(''); + expect(scoped.path()).equal('foo.bar.baz'); + }); + it('overrides a previous scope', function() { + var model = new RootModel(); + var scoped = model.scope('foo', 'bar', 'baz'); + var scoped2 = scoped.scope('colors', 4); + expect(scoped2.path()).equal('colors.4'); + }); + it('supports no arguments', function() { + var model = new RootModel(); + var scoped = model.scope('foo', 'bar', 'baz'); + var scoped2 = scoped.scope(); + expect(scoped2.path()).equal(''); + }); + }); + describe('at', function() { + it('returns a child model with the relative scope', function() { + var model = new RootModel(); + var scoped = model.at('foo.bar.baz'); + expect(model.path()).equal(''); + expect(scoped.path()).equal('foo.bar.baz'); + }); + it('supports segments as separate arguments', function() { + var model = new RootModel(); + var scoped = model.at('foo', 'bar', 'baz'); + expect(model.path()).equal(''); + expect(scoped.path()).equal('foo.bar.baz'); + }); + it('overrides a previous scope', function() { + var model = new RootModel(); + var scoped = model.at('colors'); + var scoped2 = scoped.at(4); + expect(scoped2.path()).equal('colors.4'); + }); + it('supports no arguments', function() { + var model = new RootModel(); + var scoped = model.at('foo', 'bar', 'baz'); + var scoped2 = scoped.at(); + expect(scoped2.path()).equal('foo.bar.baz'); + }); + }); +}); diff --git a/test/Model/query.js b/test/Model/query.js new file mode 100644 index 000000000..aa81ec4b1 --- /dev/null +++ b/test/Model/query.js @@ -0,0 +1,121 @@ +var expect = require('../util').expect; +var racer = require('../../lib'); +var RootModel = require('../../lib/Model').RootModel; + +describe('query', function() { + describe('sanitizeQuery', function() { + it('replaces undefined with null in object query expressions', function() { + var model = new RootModel(); + var query = model.query('foo', {x: undefined, y: 'foo'}); + expect(query.expression).eql({x: null, y: 'foo'}); + }); + it('replaces undefined with null in nested object query expressions', function() { + var model = new RootModel(); + var query = model.query('foo', [{x: undefined}, {x: {y: undefined, z: 0}}]); + expect(query.expression).eql([{x: null}, {x: {y: null, z: 0}}]); + }); + }); + + describe('Query', function() { + beforeEach('create in-memory backend and model', function() { + this.backend = racer.createBackend(); + this.model = this.backend.createModel(); + }); + it('Uses deep copy of query expression in Query constructor', function() { + var expression = {arrayKey: []}; + var query = this.model.query('myCollection', expression); + query.fetch(); + expression.arrayKey.push('foo'); + expect(query.expression.arrayKey).to.have.length(0); + }); + }); + + describe('idMap', function() { + beforeEach('create in-memory backend and model', function() { + this.backend = racer.createBackend(); + this.model = this.backend.createModel(); + }); + it('handles insert and remove of a duplicate id', function() { + var query = this.model.query('myCollection', {key: 'myVal'}); + query.subscribe(); + query.shareQuery.emit('insert', [ + {id: 'a'}, + {id: 'b'}, + {id: 'c'} + ], 0); + // Add and immediately remove a duplicate id. + query.shareQuery.emit('insert', [ + {id: 'a'} + ], 3); + query.shareQuery.emit('remove', [ + {id: 'a'} + ], 3); + // 'a' is still present once in the results, should still be in the map. + expect(query.idMap).to.have.all.keys(['a', 'b', 'c']); + }); + }); + + describe('instantiation', function() { + it('returns same instance when params are equivalent', function() { + var model = new RootModel(); + var query1 = model.query('foo', {value: 1}, {db: 'other'}); + var query2 = model.query('foo', {value: 1}, {db: 'other'}); + expect(query1).equal(query2); + }); + it('returns same instance when context and params are equivalent', function() { + var model = new RootModel(); + var query1 = model.context('box').query('foo', {}); + var query2 = model.context('box').query('foo', {}); + expect(query1).equal(query2); + }); + it('creates a unique query instance per collection name', function() { + var model = new RootModel(); + var query1 = model.query('foo', {}); + var query2 = model.query('bar', {}); + expect(query1).not.equal(query2); + }); + it('creates a unique query instance per expression', function() { + var model = new RootModel(); + var query1 = model.query('foo', {value: 1}); + var query2 = model.query('foo', {value: 2}); + expect(query1).not.equal(query2); + }); + it('creates a unique query instance per options', function() { + var model = new RootModel(); + var query1 = model.query('foo', {}, {db: 'default'}); + var query2 = model.query('foo', {}, {db: 'other'}); + expect(query1).not.equal(query2); + }); + it('creates a unique query instance per context', function() { + var model = new RootModel(); + var query1 = model.query('foo', {}); + var query2 = model.context('box').query('foo', {}); + expect(query1).not.equal(query2); + }); + }); + + describe('reference counting', function() { + it('fetch uses the root model context', function(done) { + var backend = racer.createBackend(); + var model = backend.createModel(); + var query = model.query('foo', {}); + query.fetch(function(err) { + if (err) return done(err); + expect(model._contexts.root.fetchedQueries[query.hash]).equal(1); + done(); + }); + }); + it('fetch of same query in different context uses the specified model context', function(done) { + var backend = racer.createBackend(); + var model = backend.createModel(); + model.query('foo', {}); + // Same query params in different context: + var query = model.context('box').query('foo', {}); + query.fetch(function(err) { + if (err) return done(err); + expect(model._contexts.box.fetchedQueries[query.hash]).equal(1); + done(); + }); + }); + }); +}); diff --git a/test/Model/ref.js b/test/Model/ref.js new file mode 100644 index 000000000..8eab1621e --- /dev/null +++ b/test/Model/ref.js @@ -0,0 +1,265 @@ +var expect = require('../util').expect; +var RootModel = require('../../lib/Model').RootModel; + +describe('ref', function() { + function expectEvents(pattern, model, done, events) { + model.on('all', pattern, function() { + events.shift().apply(null, arguments); + if (!events.length) done(); + }); + if (!events || !events.length) done(); + } + describe('event emission', function() { + it('re-emits on a reffed path', function(done) { + var model = new RootModel(); + model.ref('_page.color', '_page.colors.green'); + model.on('change', '_page.color', function(value) { + expect(value).to.equal('#0f0'); + done(); + }); + model.set('_page.colors.green', '#0f0'); + }); + it('also emits on the original path', function(done) { + var model = new RootModel(); + model.ref('_page.color', '_page.colors.green'); + model.on('change', '_page.colors.green', function(value) { + expect(value).to.equal('#0f0'); + done(); + }); + model.set('_page.colors.green', '#0f0'); + }); + it('re-emits on a child of a reffed path', function(done) { + var model = new RootModel(); + model.ref('_page.color', '_page.colors.green'); + model.on('change', '_page.color.*', function(capture, value) { + expect(capture).to.equal('hex'); + expect(value).to.equal('#0f0'); + done(); + }); + model.set('_page.colors.green.hex', '#0f0'); + }); + it('re-emits when a parent is changed', function(done) { + var model = new RootModel(); + model.ref('_page.color', '_page.colors.green'); + model.on('change', '_page.color', function(value) { + expect(value).to.equal('#0e0'); + done(); + }); + model.set('_page.colors', { + green: '#0e0' + }); + }); + it('re-emits on a ref to a ref', function(done) { + var model = new RootModel(); + model.ref('_page.myFavorite', '_page.color'); + model.ref('_page.color', '_page.colors.green'); + model.on('change', '_page.myFavorite', function(value) { + expect(value).to.equal('#0f0'); + done(); + }); + model.set('_page.colors.green', '#0f0'); + }); + it('re-emits on multiple reffed paths', function(done) { + var model = new RootModel(); + model.set('_page.colors.green', '#0f0'); + model.ref('_page.favorites.my', '_page.colors.green'); + model.ref('_page.favorites.your', '_page.colors.green'); + expectEvents('_page.favorites**', model, done, [ + function(capture, method, value) { + expect(method).to.equal('change'); + expect(capture).to.equal('my'); + expect(value).to.equal('#0f1'); + }, function(capture, method, value) { + expect(method).to.equal('change'); + expect(capture).to.equal('your'); + expect(value).to.equal('#0f1'); + } + ]); + model.set('_page.colors.green', '#0f1'); + }); + }); + describe('get', function() { + it('gets from a reffed path', function() { + var model = new RootModel(); + model.set('_page.colors.green', '#0f0'); + expect(model.get('_page.color')).to.equal(undefined); + model.ref('_page.color', '_page.colors.green'); + expect(model.get('_page.color')).to.equal('#0f0'); + }); + it('gets from a child of a reffed path', function() { + var model = new RootModel(); + model.set('_page.colors.green.hex', '#0f0'); + model.ref('_page.color', '_page.colors.green'); + expect(model.get('_page.color')).to.eql({ + hex: '#0f0' + }); + expect(model.get('_page.color.hex')).to.equal('#0f0'); + }); + it('gets from a ref to a ref', function() { + var model = new RootModel(); + model.ref('_page.myFavorite', '_page.color'); + model.ref('_page.color', '_page.colors.green'); + model.set('_page.colors.green', '#0f0'); + expect(model.get('_page.myFavorite')).to.equal('#0f0'); + }); + }); + describe('event/add ordering', function() { + it('ref results are propogated when set in reponse to an event', function() { + var model = new RootModel(); + model.on('change', '_page.start', function() { + model.ref('_page.myColor', '_page.color'); + model.ref('_page.yourColor', '_page.color'); + model.set('_page.yourColor', 'green'); + }); + model.set('_page.start', true); + expect(model.get('_page.color')).to.equal('green'); + expect(model.get('_page.myColor')).to.equal('green'); + }); + it('can create refList in event callback', function() { + var model = new RootModel(); + model.on('change', '_page.start', function() { + model.set('_page.colors', { + red: '#f00', + green: '#0f0', + blue: '#00f' + }); + model.set('_page.ids', ['blue', 'green']); + model.refList('_page.list', '_page.colors', '_page.ids'); + }); + model.set('_page.start', true); + expect(model.get('_page.list')).to.eql(['#00f', '#0f0']); + }); + it('removing ref on same toPath in event callback is ok', function() { + // The effects of listeners are synchronous, so while fanning out the refs + // for a given toPath - "_page.color" in this case - it's possible for one + // of the refs to be removed. Modifying during iteration can cause issues + // if not handled correctly. + var model = new RootModel(); + model.ref('_page.ref1', '_page.color'); + model.ref('_page.ref2', '_page.color'); + model.set('_page.color', 'red'); + model.once('change', '_page.ref1', function() { + model.removeRef('_page.ref2'); + }); + + model.set('_page.color', 'green'); + expect(model.get('_page.ref1')).to.eql('green'); + // ref2 was removed while processing ref, but it should still be updated + // for this current change. + expect(model.get('_page.ref2')).to.eql('green'); + + // ref2 is now removed, so it should no longer be updated. + model.set('_page.color', 'blue'); + expect(model.get('_page.ref1')).to.eql('blue'); + expect(model.get('_page.ref2')).to.eql('green'); + }); + }); + describe('updateIndices option', function() { + it('updates a ref when an array insert happens at the `to` path', function() { + var model = new RootModel(); + model.set('_page.colors', ['red', 'green', 'blue']); + model.ref('_page.color', '_page.colors.1', {updateIndices: true}); + expect(model.get('_page.color')).to.equal('green'); + model.unshift('_page.colors', 'yellow'); + expect(model.get('_page.color')).to.equal('green'); + model.push('_page.colors', 'orange'); + expect(model.get('_page.color')).to.equal('green'); + model.insert('_page.colors', 2, ['purple', 'cyan']); + expect(model.get('_page.color')).to.equal('green'); + }); + it('updates a ref when an array remove happens at the `to` path', function() { + var model = new RootModel(); + model.set('_page.colors', ['red', 'blue', 'purple', 'cyan', 'green', 'yellow']); + model.ref('_page.color', '_page.colors.4', {updateIndices: true}); + expect(model.get('_page.color')).to.equal('green'); + model.shift('_page.colors'); + expect(model.get('_page.color')).to.equal('green'); + model.pop('_page.colors'); + expect(model.get('_page.color')).to.equal('green'); + model.remove('_page.colors', 1, 2); + expect(model.get('_page.color')).to.equal('green'); + }); + it('updates a ref when an array move happens at the `to` path', function() { + var model = new RootModel(); + model.set('_page.colors', ['red', 'blue', 'purple', 'green', 'cyan', 'yellow']); + model.ref('_page.color', '_page.colors.3', {updateIndices: true}); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 0, 1); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 4, 5); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 0, 5); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 1, 3); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 0, 3, 2); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 2, 3, 2); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 3, 2, 2); + expect(model.get('_page.color')).to.equal('green'); + }); + it('updates a ref when an array insert happens within the `to` path', function() { + var model = new RootModel(); + model.set('_page.colors', [ + {name: 'red'}, + {name: 'green'}, + {name: 'blue'} + ]); + model.ref('_page.color', '_page.colors.1.name', {updateIndices: true}); + expect(model.get('_page.color')).to.equal('green'); + model.unshift('_page.colors', 'yellow'); + expect(model.get('_page.color')).to.equal('green'); + model.push('_page.colors', 'orange'); + expect(model.get('_page.color')).to.equal('green'); + model.insert('_page.colors', 2, ['purple', 'cyan']); + expect(model.get('_page.color')).to.equal('green'); + }); + it('updates a ref when an array remove happens within the `to` path', function() { + var model = new RootModel(); + model.set('_page.colors', [ + {name: 'red'}, + {name: 'blue'}, + {name: 'purple'}, + {name: 'cyan'}, + {name: 'green'}, + {name: 'yellow'} + ]); + model.ref('_page.color', '_page.colors.4.name', {updateIndices: true}); + expect(model.get('_page.color')).to.equal('green'); + model.shift('_page.colors'); + expect(model.get('_page.color')).to.equal('green'); + model.pop('_page.colors'); + expect(model.get('_page.color')).to.equal('green'); + model.remove('_page.colors', 1, 2); + expect(model.get('_page.color')).to.equal('green'); + }); + it('updates a ref when an array move happens within the `to` path', function() { + var model = new RootModel(); + model.set('_page.colors', [ + {name: 'red'}, + {name: 'blue'}, + {name: 'purple'}, + {name: 'green'}, + {name: 'cyan'}, + {name: 'yellow'} + ]); + model.ref('_page.color', '_page.colors.3.name', {updateIndices: true}); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 0, 1); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 4, 5); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 0, 5); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 1, 3); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 0, 3, 2); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 2, 3, 2); + expect(model.get('_page.color')).to.equal('green'); + model.move('_page.colors', 3, 2, 2); + expect(model.get('_page.color')).to.equal('green'); + }); + }); +}); diff --git a/test/Model/ref.mocha.coffee b/test/Model/ref.mocha.coffee deleted file mode 100644 index 43439bf31..000000000 --- a/test/Model/ref.mocha.coffee +++ /dev/null @@ -1,204 +0,0 @@ -{expect} = require '../util' -Model = require '../../lib/Model' - -describe 'ref', -> - - expectEvents = (pattern, model, done, events) -> - model.on 'all', pattern, -> - events.shift() arguments... - done() unless events.length - done() unless events?.length - - describe 'event emission', -> - - it 're-emits on a reffed path', (done) -> - model = new Model - model.ref '_page.color', '_page.colors.green' - model.on 'change', '_page.color', (value) -> - expect(value).to.equal '#0f0' - done() - model.set '_page.colors.green', '#0f0' - - it 'also emits on the original path', (done) -> - model = new Model - model.ref '_page.color', '_page.colors.green' - model.on 'change', '_page.colors.green', (value) -> - expect(value).to.equal '#0f0' - done() - model.set '_page.colors.green', '#0f0' - - it 're-emits on a child of a reffed path', (done) -> - model = new Model - model.ref '_page.color', '_page.colors.green' - model.on 'change', '_page.color.*', (capture, value) -> - expect(capture).to.equal 'hex' - expect(value).to.equal '#0f0' - done() - model.set '_page.colors.green.hex', '#0f0' - - it 're-emits when a parent is changed', (done) -> - model = new Model - model.ref '_page.color', '_page.colors.green' - model.on 'change', '_page.color', (value) -> - expect(value).to.equal '#0e0' - done() - model.set '_page.colors', - green: '#0e0' - - it 're-emits on a ref to a ref', (done) -> - model = new Model - model.ref '_page.myFavorite', '_page.color' - model.ref '_page.color', '_page.colors.green' - model.on 'change', '_page.myFavorite', (value) -> - expect(value).to.equal '#0f0' - done() - model.set '_page.colors.green', '#0f0' - - it 're-emits on multiple reffed paths', (done) -> - model = new Model - model.set '_page.colors.green', '#0f0' - model.ref '_page.favorites.my', '_page.colors.green' - model.ref '_page.favorites.your', '_page.colors.green' - - expectEvents '_page.favorites**', model, done, [ - (capture, method, value, previous) -> - expect(method).to.equal 'change' - expect(capture).to.equal 'my' - expect(value).to.equal '#0f1' - , (capture, method, value, previous) -> - expect(method).to.equal 'change' - expect(capture).to.equal 'your' - expect(value).to.equal '#0f1' - ] - model.set '_page.colors.green', '#0f1' - - describe 'get', -> - - it 'gets from a reffed path', -> - model = new Model - model.set '_page.colors.green', '#0f0' - expect(model.get '_page.color').to.equal undefined - model.ref '_page.color', '_page.colors.green' - expect(model.get '_page.color').to.equal '#0f0' - - it 'gets from a child of a reffed path', -> - model = new Model - model.set '_page.colors.green.hex', '#0f0' - model.ref '_page.color', '_page.colors.green' - expect(model.get '_page.color').to.eql {hex: '#0f0'} - expect(model.get '_page.color.hex').to.equal '#0f0' - - it 'gets from a ref to a ref', -> - model = new Model - model.ref '_page.myFavorite', '_page.color' - model.ref '_page.color', '_page.colors.green' - model.set '_page.colors.green', '#0f0' - expect(model.get '_page.myFavorite').to.equal '#0f0' - - describe 'updateIndices option', -> - - it 'updates a ref when an array insert happens at the `to` path', -> - model = new Model - model.set '_page.colors', ['red', 'green', 'blue'] - model.ref '_page.color', '_page.colors.1', {updateIndices: true} - expect(model.get '_page.color').to.equal 'green' - model.unshift '_page.colors', 'yellow' - expect(model.get '_page.color').to.equal 'green' - model.push '_page.colors', 'orange' - expect(model.get '_page.color').to.equal 'green' - model.insert '_page.colors', 2, ['purple', 'cyan'] - expect(model.get '_page.color').to.equal 'green' - - it 'updates a ref when an array remove happens at the `to` path', -> - model = new Model - model.set '_page.colors', ['red', 'blue', 'purple', 'cyan', 'green', 'yellow'] - model.ref '_page.color', '_page.colors.4', {updateIndices: true} - expect(model.get '_page.color').to.equal 'green' - model.shift '_page.colors' - expect(model.get '_page.color').to.equal 'green' - model.pop '_page.colors' - expect(model.get '_page.color').to.equal 'green' - model.remove '_page.colors', 1, 2 - expect(model.get '_page.color').to.equal 'green' - - it 'updates a ref when an array move happens at the `to` path', -> - model = new Model - model.set '_page.colors', ['red', 'blue', 'purple', 'green', 'cyan', 'yellow'] - model.ref '_page.color', '_page.colors.3', {updateIndices: true} - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 0, 1 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 4, 5 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 0, 5 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 1, 3 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 0, 3, 2 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 2, 3, 2 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 3, 2, 2 - expect(model.get '_page.color').to.equal 'green' - - it 'updates a ref when an array insert happens within the `to` path', -> - model = new Model - model.set '_page.colors', [ - {name: 'red'} - {name: 'green'} - {name: 'blue'} - ] - model.ref '_page.color', '_page.colors.1.name', {updateIndices: true} - expect(model.get '_page.color').to.equal 'green' - model.unshift '_page.colors', 'yellow' - expect(model.get '_page.color').to.equal 'green' - model.push '_page.colors', 'orange' - expect(model.get '_page.color').to.equal 'green' - model.insert '_page.colors', 2, ['purple', 'cyan'] - expect(model.get '_page.color').to.equal 'green' - - it 'updates a ref when an array remove happens within the `to` path', -> - model = new Model - model.set '_page.colors', [ - {name: 'red'} - {name: 'blue'} - {name: 'purple'} - {name: 'cyan'} - {name: 'green'} - {name: 'yellow'} - ] - model.ref '_page.color', '_page.colors.4.name', {updateIndices: true} - expect(model.get '_page.color').to.equal 'green' - model.shift '_page.colors' - expect(model.get '_page.color').to.equal 'green' - model.pop '_page.colors' - expect(model.get '_page.color').to.equal 'green' - model.remove '_page.colors', 1, 2 - expect(model.get '_page.color').to.equal 'green' - - it 'updates a ref when an array move happens within the `to` path', -> - model = new Model - model.set '_page.colors', [ - {name: 'red'} - {name: 'blue'} - {name: 'purple'} - {name: 'green'} - {name: 'cyan'} - {name: 'yellow'} - ] - model.ref '_page.color', '_page.colors.3.name', {updateIndices: true} - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 0, 1 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 4, 5 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 0, 5 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 1, 3 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 0, 3, 2 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 2, 3, 2 - expect(model.get '_page.color').to.equal 'green' - model.move '_page.colors', 3, 2, 2 - expect(model.get '_page.color').to.equal 'green' diff --git a/test/Model/refList.js b/test/Model/refList.js new file mode 100644 index 000000000..a60fac517 --- /dev/null +++ b/test/Model/refList.js @@ -0,0 +1,882 @@ +var expect = require('../util').expect; +var RootModel = require('../../lib/Model').RootModel; + +describe('refList', function() { + function setup(options) { + var model = (new RootModel()).at('_page'); + model.set('colors', { + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + }); + model.set('ids', ['red', 'green', 'red']); + model.refList('list', 'colors', 'ids', options); + return model; + } + function expectEvents(pattern, model, done, events) { + model.on('all', pattern, function() { + events.shift().apply(null, arguments); + if (!events.length) done(); + }); + if (!events || !events.length) done(); + } + function expectFromEvents(model, done, events) { + expectEvents('list**', model, done, events); + } + function expectToEvents(model, done, events) { + expectEvents('colors**', model, done, events); + } + function expectIdsEvents(model, done, events) { + expectEvents('ids**', model, done, events); + } + describe('sets output on initial call', function() { + it('sets the initial value to empty array if no inputs', function() { + var model = (new RootModel()).at('_page'); + model.refList('empty', 'colors', 'noIds'); + expect(model.get('empty')).to.eql([]); + }); + it('sets the initial value for already populated data', function() { + var model = setup(); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + }); + }); + describe('updates on `ids` mutations', function() { + it('updates the value when `ids` is set', function() { + var model = (new RootModel()).at('_page'); + model.set('colors', { + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + }); + model.refList('list', 'colors', 'ids'); + expect(model.get('list')).to.eql([]); + model.set('ids', ['red', 'green', 'red']); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + }); + it('emits on `from` when `ids` is set', function(done) { + var model = (new RootModel()).at('_page'); + model.set('colors', { + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + }); + model.refList('list', 'colors', 'ids'); + model.on('all', 'list**', function(capture, method, index, values) { + expect(capture).to.equal(''); + expect(method).to.equal('insert'); + expect(index).to.equal(0); + expect(values).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + done(); + }); + model.set('ids', ['red', 'green', 'red']); + }); + it('updates the value when `ids` children are set', function() { + var model = setup(); + model.set('ids.0', 'green'); + expect(model.get('list')).to.eql([ + { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + model.set('ids.2', 'blue'); + expect(model.get('list')).to.eql([ + { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, undefined + ]); + }); + it('emits on `from` when `ids` children are set', function(done) { + var model = setup(); + model.on('all', 'list**', function(capture, method, value, previous) { + expect(capture).to.equal('2'); + expect(method).to.equal('change'); + expect(value).to.eql({ + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }); + expect(previous).to.eql({ + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }); + done(); + }); + model.set('ids.2', 'green'); + }); + it('updates the value when `ids` are inserted', function() { + var model = setup(); + model.push('ids', 'green'); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + } + ]); + model.insert('ids', 1, ['blue', 'red']); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, undefined, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + } + ]); + }); + it('emits on `from` when `ids` are inserted', function(done) { + var model = setup(); + model.on('all', 'list**', function(capture, method, index, inserted) { + expect(capture).to.equal(''); + expect(method).to.equal('insert'); + expect(index).to.equal(1); + expect(inserted).to.eql([ + undefined, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + done(); + }); + model.insert('ids', 1, ['blue', 'red']); + }); + it('updates the value when `ids` are removed', function() { + var model = setup(); + model.pop('ids'); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + } + ]); + model.remove('ids', 0, 2); + expect(model.get('list')).to.eql([]); + }); + it('emits on `from` when `ids` are removed', function(done) { + var model = setup(); + model.on('all', 'list**', function(capture, method, index, removed) { + expect(capture).to.equal(''); + expect(method).to.equal('remove'); + expect(index).to.equal(0); + expect(removed).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + } + ]); + done(); + }); + model.remove('ids', 0, 2); + }); + it('updates the value when `ids` are moved', function() { + var model = setup(); + model.move('ids', 0, 2, 2); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + } + ]); + model.move('ids', 2, 0); + expect(model.get('list')).to.eql([ + { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + }); + it('emits on `from` when `ids` are moved', function(done) { + var model = setup(); + model.on('all', 'list**', function(capture, method, from, to, howMany) { + expect(capture).to.equal(''); + expect(method).to.equal('move'); + expect(from).to.equal(0); + expect(to).to.equal(2); + expect(howMany).to.eql(2); + done(); + }); + model.move('ids', 0, 2, 2); + }); + }); + describe('emits events involving multiple refLists', function() { + it('removes data from a refList pointing to data in another refList', function() { + var model = (new RootModel()).at('_page'); + var tagId = model.add('tags', { + text: 'hi' + }); + var tagIds = [tagId]; + var id = model.add('profiles', { + tagIds: tagIds + }); + model.push('profileIds', id); + model.refList('profilesList', 'profiles', 'profileIds'); + model.ref('profile', 'profilesList.0'); + model.refList('tagsList', 'tags', 'profile.tagIds'); + model.remove('tagsList', 0); + }); + }); + describe('updates on `to` mutations', function() { + it('updates the value when `to` is set', function() { + var model = (new RootModel()).at('_page'); + model.set('ids', ['red', 'green', 'red']); + model.refList('list', 'colors', 'ids'); + expect(model.get('list')).to.eql([undefined, undefined, undefined]); + model.set('colors', { + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + }); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + }); + it('emits on `from` when `to` is set', function(done) { + var model = (new RootModel()).at('_page'); + model.set('ids', ['red', 'green', 'red']); + model.refList('list', 'colors', 'ids'); + expectFromEvents(model, done, [ + function(capture, method, index, removed) { + expect(capture).to.equal(''); + expect(method).to.equal('remove'); + expect(index).to.equal(0); + expect(removed).to.eql([undefined, undefined, undefined]); + }, function(capture, method, index, inserted) { + expect(capture).to.equal(''); + expect(method).to.equal('insert'); + expect(index).to.equal(0); + expect(inserted).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + } + ]); + model.set('colors', { + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + }); + }); + it('updates the value when `to` children are set', function() { + var model = (new RootModel()).at('_page'); + model.set('ids', ['red', 'green', 'red']); + model.refList('list', 'colors', 'ids'); + model.set('colors.green', { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }); + expect(model.get('list')).to.eql([ + undefined, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, undefined + ]); + model.set('colors.red', { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + model.del('colors.green'); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, undefined, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + }); + it('emits on `from` when `to` children are set', function(done) { + var model = (new RootModel()).at('_page'); + model.set('ids', ['red', 'green', 'red']); + model.refList('list', 'colors', 'ids'); + expectFromEvents(model, done, [ + function(capture, method, value, previous) { + expect(capture).to.equal('0'); + expect(method).to.equal('change'); + expect(value).to.eql({ + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }); + expect(previous).to.equal(undefined); + }, function(capture, method, value, previous) { + expect(capture).to.equal('2'); + expect(method).to.equal('change'); + expect(value).to.eql({ + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }); + expect(previous).to.equal(undefined); + } + ]); + model.set('colors.red', { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }); + }); + it('updates the value when `to` descendants are set', function() { + var model = setup(); + model.set('colors.red.hex', '#e00'); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [255, 0, 0], + hex: '#e00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#e00' + } + ]); + model.set('colors.red.rgb.0', 238); + expect(model.get('list')).to.eql([ + { + id: 'red', + rgb: [238, 0, 0], + hex: '#e00' + }, { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [238, 0, 0], + hex: '#e00' + } + ]); + }); + it('emits on `from` when `to` descendants are set', function(done) { + var model = setup(); + expectFromEvents(model, done, [ + function(capture, method, value, previous) { + expect(capture).to.equal('0.hex'); + expect(method).to.equal('change'); + expect(value).to.eql('#e00'); + expect(previous).to.equal('#f00'); + }, function(capture, method, value, previous) { + expect(capture).to.equal('2.hex'); + expect(method).to.equal('change'); + expect(value).to.eql('#e00'); + expect(previous).to.equal('#f00'); + }, function(capture, method, value, previous) { + expect(capture).to.equal('0.rgb.0'); + expect(method).to.equal('change'); + expect(value).to.eql(238); + expect(previous).to.equal(255); + }, function(capture, method, value, previous) { + expect(capture).to.equal('2.rgb.0'); + expect(method).to.equal('change'); + expect(value).to.eql(238); + expect(previous).to.equal(255); + } + ]); + model.set('colors.red.hex', '#e00'); + model.set('colors.red.rgb.0', 238); + }); + it('updates the value when inserting on `to` children', function() { + var model = (new RootModel()).at('_page'); + model.set('nums', { + even: [2, 4, 6], + odd: [1, 3] + }); + model.set('ids', ['even', 'odd', 'even']); + model.refList('list', 'nums', 'ids'); + expect(model.get('list')).to.eql([[2, 4, 6], [1, 3], [2, 4, 6]]); + model.push('nums.even', 8); + expect(model.get('list')).to.eql([[2, 4, 6, 8], [1, 3], [2, 4, 6, 8]]); + }); + it('emits on `from` when inserting on `to` children', function(done) { + var model = (new RootModel()).at('_page'); + model.set('nums', { + even: [2, 4, 6], + odd: [1, 3] + }); + model.set('ids', ['even', 'odd', 'even']); + model.refList('list', 'nums', 'ids'); + expectFromEvents(model, done, [ + function(capture, method, index, inserted) { + expect(capture).to.equal('0'); + expect(method).to.equal('insert'); + expect(index).to.equal(3); + expect(inserted).to.eql([8]); + }, function(capture, method, index, inserted) { + expect(capture).to.equal('2'); + expect(method).to.equal('insert'); + expect(index).to.equal(3); + expect(inserted).to.eql([8]); + } + ]); + model.push('nums.even', 8); + }); + }); + describe('updates on `from` mutations', function() { + it('updates `to` and `ids` when `from` is set', function() { + var model = setup(); + model.set('list', [ + { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + expect(model.get('ids')).to.eql(['green', 'red']); + expect(model.get('colors')).to.eql({ + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + }); + model.del('list'); + expect(model.get('ids')).to.eql([]); + expect(model.get('colors')).to.eql({ + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + }); + model.set('list', [ + { + id: 'blue', + rgb: [0, 0, 255], + hex: '#00f' + }, { + id: 'yellow', + rgb: [255, 255, 0], + hex: '#ff0' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + ]); + expect(model.get('ids')).to.eql(['blue', 'yellow', 'red']); + expect(model.get('colors')).to.eql({ + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, + blue: { + id: 'blue', + rgb: [0, 0, 255], + hex: '#00f' + }, + yellow: { + id: 'yellow', + rgb: [255, 255, 0], + hex: '#ff0' + } + }); + model.at('list.0').remove(); + expect(model.get('ids')).to.eql(['yellow', 'red']); + expect(model.get('colors')).to.eql({ + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, + blue: { + id: 'blue', + rgb: [0, 0, 255], + hex: '#00f' + }, + yellow: { + id: 'yellow', + rgb: [255, 255, 0], + hex: '#ff0' + } + }); + }); + it('emits on `to` when `from` is set', function(done) { + var model = setup(); + expectToEvents(model, done, [ + function(capture, method, value, previous) { + expect(capture).to.equal('blue'); + expect(method).to.equal('change'); + expect(value).to.eql({ + id: 'blue', + rgb: [0, 0, 255], + hex: '#00f' + }); + expect(previous).to.eql(undefined); + }, function(capture, method, value, previous) { + expect(capture).to.equal('yellow'); + expect(method).to.equal('change'); + expect(value).to.eql({ + id: 'yellow', + rgb: [255, 255, 0], + hex: '#ff0' + }); + expect(previous).to.eql(undefined); + } + ]); + model.set('list', [ + { + id: 'blue', + rgb: [0, 0, 255], + hex: '#00f' + }, model.get('colors.red'), { + id: 'yellow', + rgb: [255, 255, 0], + hex: '#ff0' + } + ]); + }); + it('emits on `ids` when `from is set', function(done) { + var model = setup(); + expectIdsEvents(model, done, [ + function(capture, method, value, previous) { + expect(capture).to.equal(''); + expect(method).to.equal('change'); + expect(value).to.eql(['blue', 'red', 'yellow']); + expect(previous).to.eql(['red', 'green', 'red']); + } + ]); + model.set('list', [ + { + id: 'blue', + rgb: [0, 0, 255], + hex: '#00f' + }, { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + }, { + id: 'yellow', + rgb: [255, 255, 0], + hex: '#ff0' + } + ]); + }); + it('emits nothing on `to` when `from` is set, removing items', function(done) { + var model = setup(); + expectToEvents(model, done, []); + model.set('list', []); + }); + it('creates a document in `to` on an insert', function() { + var model = setup(); + model.insert('list', 0, { + id: 'yellow' + }); + expect(model.get('colors.yellow')).to.eql({ + id: 'yellow' + }); + }); + it('creates a document in `to` on an insert of a doc with no id', function() { + var model = setup(); + model.insert('list', 0, { + rgb: [1, 1, 1] + }); + var newId = model.get('list.0').id; + expect(model.get('colors.' + newId)).to.eql({ + id: newId, + rgb: [1, 1, 1] + }); + }); + }); + describe('event ordering', function() { + it('should be able to resolve a non-existent nested property as undefined, inside an event listener on refA (where refA -> refList)', function(done) { + var model = setup(); + model.refList('array', 'colors', 'arrayIds'); + model.ref('arrayAlias', 'array'); + model.on('insert', 'arrayAlias', function() { + expect(model.get('array.0.names.0')).to.eql(undefined); + done(); + }); + model.insert('arrayAlias', 0, { + rgb: [1, 1, 1] + }); + expect(model.get('arrayIds')).to.have.length(1); + }); + // This is a bug. Currently skipping it, since it is a complex edge case and + // really may require thinking how events + mutations are propogated in refs + it.skip('correctly dereferences chained lists/refs when items are removed', function(done) { + var model = setup(); + model.add('colors', { + id: 'blue', + rgb: [0, 0, 255], + hex: '#00f' + }); + model.add('colors', { + id: 'white', + rgb: [255, 255, 255], + hex: '#fff' + }); + model.set('palettes', { + nature: { + id: 'nature', + colors: ['green', 'blue', 'white'] + }, + flag: { + id: 'flag', + colors: ['red', 'white', 'blue'] + } + }); + model.set('schemes', ['nature', 'flag']); + model.refList('choices', 'palettes', 'schemes'); + model.ref('choice', 'choices.0'); + var paint = model.refList('paint', 'colors', 'choice.colors'); + paint.on('remove', function(index, removed) { + expect(index).to.equal(1); + expect(removed).to.eql([ + { + id: 'blue', + rgb: [0, 0, 255], + hex: '#00f' + } + ]); + done(); + }); + paint.remove(1); + }); + }); + describe('deleteRemoved', function() { + it('deletes the underlying object when an item is removed', function() { + var model = setup({deleteRemoved: true}); + expect(model.get('colors')).to.eql({ + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + }, + red: { + id: 'red', + rgb: [255, 0, 0], + hex: '#f00' + } + }); + model.remove('list', 0); + expect(model.get('colors')).to.eql({ + green: { + id: 'green', + rgb: [0, 255, 0], + hex: '#0f0' + } + }); + }); + }); +}); diff --git a/test/Model/refList.mocha.coffee b/test/Model/refList.mocha.coffee deleted file mode 100644 index ee1afaf0b..000000000 --- a/test/Model/refList.mocha.coffee +++ /dev/null @@ -1,522 +0,0 @@ -{expect} = require '../util' -Model = require '../../lib/Model' - -describe 'refList', -> - - setup = (options) -> - model = (new Model).at '_page' - model.set 'colors', - green: - id: 'green' - rgb: [0, 255, 0] - hex: '#0f0' - red: - id: 'red' - rgb: [255, 0, 0] - hex: '#f00' - model.set 'ids', ['red', 'green', 'red'] - model.refList 'list', 'colors', 'ids', options - return model - - expectEvents = (pattern, model, done, events) -> - model.on 'all', pattern, -> - events.shift() arguments... - done() unless events.length - done() unless events?.length - expectFromEvents = (model, done, events) -> - expectEvents 'list**', model, done, events - expectToEvents = (model, done, events) -> - expectEvents 'colors**', model, done, events - expectIdsEvents = (model, done, events) -> - expectEvents 'ids**', model, done, events - - describe 'sets output on initial call', -> - - it 'sets the initial value to empty array if no inputs', -> - model = (new Model).at '_page' - model.refList 'empty', 'colors', 'noIds' - expect(model.get 'empty').to.eql [] - - it 'sets the initial value for already populated data', -> - model = setup() - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - - describe 'updates on `ids` mutations', -> - - it 'updates the value when `ids` is set', -> - model = (new Model).at '_page' - model.set 'colors', - green: - id: 'green' - rgb: [0, 255, 0] - hex: '#0f0' - red: - id: 'red' - rgb: [255, 0, 0] - hex: '#f00' - model.refList 'list', 'colors', 'ids' - expect(model.get 'list').to.eql [] - model.set 'ids', ['red', 'green', 'red'] - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - - it 'emits on `from` when `ids` is set', (done) -> - model = (new Model).at '_page' - model.set 'colors', - green: - id: 'green' - rgb: [0, 255, 0] - hex: '#0f0' - red: - id: 'red' - rgb: [255, 0, 0] - hex: '#f00' - model.refList 'list', 'colors', 'ids' - model.on 'all', 'list**', (capture, method, index, values) -> - expect(capture).to.equal '' - expect(method).to.equal 'insert' - expect(index).to.equal 0 - expect(values).to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - done() - model.set 'ids', ['red', 'green', 'red'] - - it 'updates the value when `ids` children are set', -> - model = setup() - model.set 'ids.0', 'green' - expect(model.get 'list').to.eql [ - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - model.set 'ids.2', 'blue' - expect(model.get 'list').to.eql [ - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - undefined - ] - - it 'emits on `from` when `ids` children are set', (done) -> - model = setup() - model.on 'all', 'list**', (capture, method, value, previous) -> - expect(capture).to.equal '2' - expect(method).to.equal 'change' - expect(value).to.eql {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - expect(previous).to.eql {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - done() - model.set 'ids.2', 'green' - - it 'updates the value when `ids` are inserted', -> - model = setup() - model.push 'ids', 'green' - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - ] - model.insert 'ids', 1, ['blue', 'red'] - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - undefined - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - ] - - it 'emits on `from` when `ids` are inserted', (done) -> - model = setup() - model.on 'all', 'list**', (capture, method, index, inserted) -> - expect(capture).to.equal '' - expect(method).to.equal 'insert' - expect(index).to.equal 1 - expect(inserted).to.eql [ - undefined - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - done() - model.insert 'ids', 1, ['blue', 'red'] - - it 'updates the value when `ids` are removed', -> - model = setup() - model.pop 'ids' - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - ] - model.remove 'ids', 0, 2 - expect(model.get 'list').to.eql [] - - it 'emits on `from` when `ids` are removed', (done) -> - model = setup() - model.on 'all', 'list**', (capture, method, index, removed) -> - expect(capture).to.equal '' - expect(method).to.equal 'remove' - expect(index).to.equal 0 - expect(removed).to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - ] - done() - model.remove 'ids', 0, 2 - - it 'updates the value when `ids` are moved', -> - model = setup() - model.move 'ids', 0, 2, 2 - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - ] - model.move 'ids', 2, 0 - expect(model.get 'list').to.eql [ - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - - it 'emits on `from` when `ids` are moved', (done) -> - model = setup() - model.on 'all', 'list**', (capture, method, from, to, howMany) -> - expect(capture).to.equal '' - expect(method).to.equal 'move' - expect(from).to.equal 0 - expect(to).to.equal 2 - expect(howMany).to.eql 2 - done() - model.move 'ids', 0, 2, 2 - - describe 'emits events involving multiple refLists', -> - it 'removes data from a refList pointing to data in another refList', -> - model = (new Model).at '_page' - tagId = model.add 'tags', { text: 'hi' } - tagIds = [tagId] - - #profiles collection - id = model.add 'profiles', { tagIds: tagIds } - model.push 'profileIds', id - model.refList 'profilesList', 'profiles', 'profileIds' - - #ref a single item from collection - model.ref 'profile', 'profilesList.0' - - #remove from nested refList - model.refList 'tagsList', 'tags', 'profile.tagIds' - model.remove('tagsList', 0) - - describe 'updates on `to` mutations', -> - - it 'updates the value when `to` is set', -> - model = (new Model).at '_page' - model.set 'ids', ['red', 'green', 'red'] - model.refList 'list', 'colors', 'ids' - expect(model.get 'list').to.eql [undefined, undefined, undefined] - model.set 'colors', - green: - id: 'green' - rgb: [0, 255, 0] - hex: '#0f0' - red: - id: 'red' - rgb: [255, 0, 0] - hex: '#f00' - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - - it 'emits on `from` when `to` is set', (done) -> - model = (new Model).at '_page' - model.set 'ids', ['red', 'green', 'red'] - model.refList 'list', 'colors', 'ids' - expectFromEvents model, done, [ - (capture, method, index, removed) -> - expect(capture).to.equal '' - expect(method).to.equal 'remove' - expect(index).to.equal 0 - expect(removed).to.eql [undefined, undefined, undefined] - , (capture, method, index, inserted) -> - expect(capture).to.equal '' - expect(method).to.equal 'insert' - expect(index).to.equal 0 - expect(inserted).to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - ] - model.set 'colors', - green: - id: 'green' - rgb: [0, 255, 0] - hex: '#0f0' - red: - id: 'red' - rgb: [255, 0, 0] - hex: '#f00' - - it 'updates the value when `to` children are set', -> - model = (new Model).at '_page' - model.set 'ids', ['red', 'green', 'red'] - model.refList 'list', 'colors', 'ids' - model.set 'colors.green', - id: 'green' - rgb: [0, 255, 0] - hex: '#0f0' - expect(model.get 'list').to.eql [ - undefined - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - undefined - ] - model.set 'colors.red', - id: 'red' - rgb: [255, 0, 0] - hex: '#f00' - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - model.del 'colors.green' - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - undefined - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - - it 'emits on `from` when `to` children are set', (done) -> - model = (new Model).at '_page' - model.set 'ids', ['red', 'green', 'red'] - model.refList 'list', 'colors', 'ids' - expectFromEvents model, done, [ - (capture, method, value, previous) -> - expect(capture).to.equal '0' - expect(method).to.equal 'change' - expect(value).to.eql {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - expect(previous).to.equal undefined - , (capture, method, value, previous) -> - expect(capture).to.equal '2' - expect(method).to.equal 'change' - expect(value).to.eql {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - expect(previous).to.equal undefined - ] - model.set 'colors.red', - id: 'red' - rgb: [255, 0, 0] - hex: '#f00' - - it 'updates the value when `to` descendants are set', -> - model = setup() - model.set 'colors.red.hex', '#e00' - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [255, 0, 0], hex: '#e00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#e00'} - ] - model.set 'colors.red.rgb.0', 238 - expect(model.get 'list').to.eql [ - {id: 'red', rgb: [238, 0, 0], hex: '#e00'} - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [238, 0, 0], hex: '#e00'} - ] - - it 'emits on `from` when `to` descendants are set', (done) -> - model = setup() - expectFromEvents model, done, [ - (capture, method, value, previous) -> - expect(capture).to.equal '0.hex' - expect(method).to.equal 'change' - expect(value).to.eql '#e00' - expect(previous).to.equal '#f00' - , (capture, method, value, previous) -> - expect(capture).to.equal '2.hex' - expect(method).to.equal 'change' - expect(value).to.eql '#e00' - expect(previous).to.equal '#f00' - , (capture, method, value, previous) -> - expect(capture).to.equal '0.rgb.0' - expect(method).to.equal 'change' - expect(value).to.eql 238 - expect(previous).to.equal 255 - , (capture, method, value, previous) -> - expect(capture).to.equal '2.rgb.0' - expect(method).to.equal 'change' - expect(value).to.eql 238 - expect(previous).to.equal 255 - ] - model.set 'colors.red.hex', '#e00' - model.set 'colors.red.rgb.0', 238 - - it 'updates the value when inserting on `to` children', -> - model = (new Model).at '_page' - model.set 'nums', - even: [2, 4, 6] - odd: [1, 3] - model.set 'ids', ['even', 'odd', 'even'] - model.refList 'list', 'nums', 'ids' - expect(model.get 'list').to.eql [ - [2, 4, 6] - [1, 3] - [2, 4, 6] - ] - model.push 'nums.even', 8 - expect(model.get 'list').to.eql [ - [2, 4, 6, 8] - [1, 3] - [2, 4, 6, 8] - ] - - it 'emits on `from` when inserting on `to` children', (done) -> - model = (new Model).at '_page' - model.set 'nums', - even: [2, 4, 6] - odd: [1, 3] - model.set 'ids', ['even', 'odd', 'even'] - model.refList 'list', 'nums', 'ids' - expectFromEvents model, done, [ - (capture, method, index, inserted) -> - expect(capture).to.equal '0' - expect(method).to.equal 'insert' - expect(index).to.equal 3 - expect(inserted).to.eql [8] - , (capture, method, index, inserted) -> - expect(capture).to.equal '2' - expect(method).to.equal 'insert' - expect(index).to.equal 3 - expect(inserted).to.eql [8] - ] - model.push 'nums.even', 8 - - describe 'updates on `from` mutations', -> - - it 'updates `to` and `ids` when `from` is set', -> - model = setup() - model.set 'list', [ - {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - expect(model.get 'ids').to.eql ['green', 'red'] - expect(model.get 'colors').to.eql - green: {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - red: {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - # Changing items in the `from` list can only create new objects - # under `to`, and it does not remove them - model.del 'list' - expect(model.get 'ids').to.eql [] - expect(model.get 'colors').to.eql - green: {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - red: {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - model.set 'list', [ - {id: 'blue', rgb: [0, 0, 255], hex: '#00f'} - {id: 'yellow', rgb: [255, 255, 0], hex: '#ff0'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - ] - expect(model.get 'ids').to.eql ['blue', 'yellow', 'red'] - expect(model.get 'colors').to.eql - green: {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - red: {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - blue: {id: 'blue', rgb: [0, 0, 255], hex: '#00f'} - yellow: {id: 'yellow', rgb: [255, 255, 0], hex: '#ff0'} - - model.at('list.0').remove() - expect(model.get 'ids').to.eql ['yellow', 'red'] - expect(model.get 'colors').to.eql - green: {id: 'green', rgb: [0, 255, 0], hex: '#0f0'} - red: {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - blue: {id: 'blue', rgb: [0, 0, 255], hex: '#00f'} - yellow: {id: 'yellow', rgb: [255, 255, 0], hex: '#ff0'} - - it 'emits on `to` when `from` is set', (done) -> - model = setup() - expectToEvents model, done, [ - (capture, method, value, previous) -> - expect(capture).to.equal 'blue' - expect(method).to.equal 'change' - expect(value).to.eql {id: 'blue', rgb: [0, 0, 255], hex: '#00f'} - expect(previous).to.eql undefined - (capture, method, value, previous) -> - expect(capture).to.equal 'yellow' - expect(method).to.equal 'change' - expect(value).to.eql {id: 'yellow', rgb: [255, 255, 0], hex: '#ff0'} - expect(previous).to.eql undefined - ] - model.set 'list', [ - {id: 'blue', rgb: [0, 0, 255], hex: '#00f'} - model.get('colors.red') - {id: 'yellow', rgb: [255, 255, 0], hex: '#ff0'} - ] - - it 'emits on `ids` when `from is set', (done) -> - model = setup() - expectIdsEvents model, done, [ - (capture, method, value, previous) -> - expect(capture).to.equal '' - expect(method).to.equal 'change' - expect(value).to.eql ['blue', 'red', 'yellow'] - expect(previous).to.eql ['red', 'green', 'red'] - ] - model.set 'list', [ - {id: 'blue', rgb: [0, 0, 255], hex: '#00f'} - {id: 'red', rgb: [255, 0, 0], hex: '#f00'} - {id: 'yellow', rgb: [255, 255, 0], hex: '#ff0'} - ] - - it 'emits nothing on `to` when `from` is set, removing items', (done) -> - model = setup() - expectToEvents model, done, [] - model.set 'list', [] - - it 'creates a document in `to` on an insert', -> - model = setup() - model.insert 'list', 0, {id: 'yellow'} - expect(model.get('colors.yellow')).to.eql {id: 'yellow'} - - it 'creates a document in `to` on an insert of a doc with no id', -> - model = setup() - model.insert 'list', 0, {rgb: [1, 1, 1]} - newId = model.get('list.0').id - expect(model.get("colors.#{newId}")).to.eql {id: newId, rgb: [1, 1, 1]} - - describe 'event ordering', -> - - it 'should be able to resolve a non-existent nested property as undefined, inside an event listener on refA (where refA -> refList)', (done) -> - model = setup() - model.refList 'array', 'colors', 'arrayIds' - model.ref 'arrayAlias', 'array' - model.on 'insert', 'arrayAlias', -> - expect(model.get 'array.0.names.0').to.eql undefined - done() - model.insert 'arrayAlias', 0, {rgb: [1, 1, 1]} - - expect(model.get 'arrayIds').to.have.length(1) - - describe 'deleteRemoved', -> - it 'deletes the underlying object when an item is removed', -> - model = setup {deleteRemoved: true} - expect(model.get 'colors').to.eql - green: - id: 'green' - rgb: [0, 255, 0] - hex: '#0f0' - red: - id: 'red' - rgb: [255, 0, 0] - hex: '#f00' - model.remove 'list', 0 - expect(model.get 'colors').to.eql - green: - id: 'green' - rgb: [0, 255, 0] - hex: '#0f0' diff --git a/test/Model/setDiff.js b/test/Model/setDiff.js new file mode 100644 index 000000000..f5513482d --- /dev/null +++ b/test/Model/setDiff.js @@ -0,0 +1,242 @@ +var expect = require('../util').expect; +var RootModel = require('../../lib/Model').RootModel; + +['setDiff', 'setDiffDeep', 'setArrayDiff', 'setArrayDiffDeep'].forEach(function(method) { + describe(method + ' common diff functionality', function() { + it('sets the value when undefined', function() { + var model = new RootModel(); + model[method]('_page.color', 'green'); + expect(model.get('_page.color')).to.equal('green'); + }); + + it('changes the value', function() { + var model = new RootModel(); + model.set('_page.color', 'green'); + model[method]('_page.color', 'red'); + expect(model.get('_page.color')).to.equal('red'); + }); + + it('changes an object', function() { + var model = new RootModel(); + model.set('_page.color', {hex: '#0f0', name: 'green'}); + model[method]('_page.color', {hex: '#f00', name: 'red'}); + expect(model.get('_page.color')).to.eql({hex: '#f00', name: 'red'}); + }); + + it('deletes keys from an object', function() { + var model = new RootModel(); + model.set('_page.color', {hex: '#0f0', name: 'green'}); + model[method]('_page.color', {name: 'green'}); + expect(model.get('_page.color')).to.eql({name: 'green'}); + }); + + it('adds items to an array', function() { + var model = new RootModel(); + model.set('_page.items', [4]); + model[method]('_page.items', [2, 3, 4]); + expect(model.get('_page.items')).to.eql([2, 3, 4]); + }); + + it('removes items in an array', function() { + var model = new RootModel(); + model.set('_page.items', [2, 3, 4]); + model[method]('_page.items', [3, 4]); + expect(model.get('_page.items')).to.eql([3, 4]); + }); + + it('moves items in an array', function() { + var model = new RootModel(); + model.set('_page.items', [2, 3, 4]); + model[method]('_page.items', [3, 4, 2]); + expect(model.get('_page.items')).to.eql([3, 4, 2]); + }); + + it('adds items to an array in an object', function() { + var model = new RootModel(); + model.set('_page.lists', {a: [4]}); + model[method]('_page.lists', {a: [2, 3, 4]}); + expect(model.get('_page.lists')).to.eql({a: [2, 3, 4]}); + }); + + it('emits an event when changing value', function(done) { + var model = new RootModel(); + model.on('all', function(segments, event) { + expect(segments).eql(['_page', 'color']); + expect(event.type).equal('change'); + expect(event.value).equal('green'); + expect(event.previous).equal(undefined); + done(); + }); + model[method]('_page.color', 'green'); + }); + + it('does not emit an event when value is not changed', function(done) { + var model = new RootModel(); + model.set('_page.color', 'green'); + model.on('all', function() { + done(new Error('unexpected event emission')); + }); + model[method]('_page.color', 'green'); + done(); + }); + }); +}); + +describe('setDiff', function() { + it('emits an event when an object is set to an equivalent object', function(done) { + var model = new RootModel(); + model.set('_page.color', {name: 'green'}); + model.on('all', function(segments, event) { + expect(segments).eql(['_page', 'color']); + expect(event.type).equal('change'); + expect(event.value).eql({name: 'green'}); + expect(event.previous).eql({name: 'green'}); + done(); + }); + model.setDiff('_page.color', {name: 'green'}); + }); + + it('emits an event when an array is set to an equivalent array', function(done) { + var model = new RootModel(); + model.set('_page.list', [2, 3, 4]); + model.on('all', function(segments, event) { + expect(segments).eql(['_page', 'list']); + expect(event.type).equal('change'); + expect(event.value).eql([2, 3, 4]); + expect(event.previous).eql([2, 3, 4]); + done(); + }); + model.setDiff('_page.list', [2, 3, 4]); + }); +}); + +describe('setDiffDeep', function() { + it('does not emit an event when an object is set to an equivalent object', function(done) { + var model = new RootModel(); + model.set('_page.color', {name: 'green'}); + model.on('all', function() { + done(new Error('unexpected event emission')); + }); + model.setDiffDeep('_page.color', {name: 'green'}); + done(); + }); + + it('does not emit an event when an array is set to an equivalent array', function(done) { + var model = new RootModel(); + model.set('_page.list', [2, 3, 4]); + model.on('all', function() { + done(new Error('unexpected event emission')); + }); + model.setDiffDeep('_page.list', [2, 3, 4]); + done(); + }); + + it('does not emit an event when a deep object / array is set to an equivalent value', function(done) { + var model = new RootModel(); + model.set('_page.lists', {a: [2, 3], b: [1], _meta: {foo: 'bar'}}); + model.on('all', function() { + done(new Error('unexpected event emission')); + }); + model.setDiffDeep('_page.lists', {a: [2, 3], b: [1], _meta: {foo: 'bar'}}); + done(); + }); + + it('equivalent objects ignore key order', function(done) { + var model = new RootModel(); + model.set('_page.lists', {a: [2, 3], b: [1]}); + model.on('all', function() { + done(new Error('unexpected event emission')); + }); + model.setDiffDeep('_page.lists', {b: [1], a: [2, 3]}); + done(); + }); + + it('adds items to an array', function(done) { + var model = new RootModel(); + model.set('_page.items', [4]); + model.on('all', function(segments, event) { + expect(segments).eql(['_page', 'items']); + expect(event.type).equal('insert'); + expect(event.values).eql([2, 3]); + expect(event.index).eql(0); + done(); + }); + model.setDiffDeep('_page.items', [2, 3, 4]); + }); + + it('adds items to an array in an object', function(done) { + var model = new RootModel(); + model.set('_page.lists', {a: [4]}); + model.on('all', function(segments, event) { + expect(segments).eql(['_page', 'lists', 'a']); + expect(event.type).equal('insert'); + expect(event.values).eql([2, 3]); + expect(event.index).eql(0); + done(); + }); + model.setDiffDeep('_page.lists', {a: [2, 3, 4]}); + }); + + it('emits a delete event when a key is removed from an object', function(done) { + var model = new RootModel(); + model.set('_page.color', {hex: '#0f0', name: 'green'}); + model.on('all', function(segments, event) { + expect(segments).eql(['_page', 'color', 'hex']); + expect(event.type).equal('change'); + expect(event.value).equal(undefined); + expect(event.previous).equal('#0f0'); + done(); + }); + model.setDiffDeep('_page.color', {name: 'green'}); + expect(model.get('_page.color')).to.eql({name: 'green'}); + }); +}); + +describe('setArrayDiff', function() { + it('does not emit an event when an array is set to an equivalent array', function(done) { + var model = new RootModel(); + model.set('_page.list', [2, 3, 4]); + model.on('all', function() { + done(new Error('unexpected event emission')); + }); + model.setArrayDiff('_page.list', [2, 3, 4]); + done(); + }); + + it('emits an event when objects in an array are set to an equivalent array', function(done) { + var model = new RootModel(); + model.set('_page.list', [{a: 2}, {c: 3}, {b: 4}]); + var expectedEvents = ['remove', 'insert']; + model.on('all', function(segments, event) { + expect(segments).eql(['_page', 'list']); + var expected = expectedEvents.shift(); + expect(event.type).equal(expected); + expect(event.values).eql([{a: 2}, {c: 3}, {b: 4}]); + expect(event.index).eql(0); + if (expectedEvents.length === 0) done(); + }); + model.setArrayDiff('_page.list', [{a: 2}, {c: 3}, {b: 4}]); + }); +}); + +describe('setArrayDiffDeep', function() { + it('does not emit an event when an array is set to an equivalent array', function(done) { + var model = new RootModel(); + model.set('_page.list', [2, 3, 4]); + model.on('all', function() { + done(new Error('unexpected event emission')); + }); + model.setArrayDiffDeep('_page.list', [2, 3, 4]); + done(); + }); + + it('does not emit an event when objects in an array are set to an equivalent array', function(done) { + var model = new RootModel(); + model.set('_page.list', [{a: 2}, {c: 3}, {b: 4}]); + model.on('all', function() { + done(new Error('unexpected event emission')); + }); + model.setArrayDiffDeep('_page.list', [{a: 2}, {c: 3}, {b: 4}]); + done(); + }); +}); diff --git a/test/Model/unbundle.js b/test/Model/unbundle.js new file mode 100644 index 000000000..7020740b3 --- /dev/null +++ b/test/Model/unbundle.js @@ -0,0 +1,39 @@ +var expect = require('../util').expect; +var racer = require('../../lib/index'); + +describe('unbundle', function() { + it('dedupes against existing queries', function(done) { + var backend = racer.createBackend(); + var setupModel = backend.createModel(); + setupModel.add('dogs', {id: 'fido', name: 'Fido'}); + setupModel.whenNothingPending(function() { + var model1 = backend.createModel({fetchOnly: true}); + var model1Query = model1.query('dogs', {}); + model1.subscribe(model1Query, function() { + model1.bundle(function(err, bundleData) { + if (err) { + return done(err); + } + // Simulate serialization of bundle data between server and client. + bundleData = JSON.parse(JSON.stringify(bundleData)); + + var model2 = backend.createModel(); + // Unbundle should load data into model. + model2.unbundle(bundleData); + expect(model2.get('dogs.fido.name')).to.eql('Fido'); + // Unloaded data available until after `unloadDelay` ms has elapsed. + model2.unloadDelay = 4; + model2.unload(); + expect(model2.get('dogs.fido.name')).to.eql('Fido'); + // Another unbundle should re-increment subscribe count, so the data + // should still be present even after `unloadDelay` has passed. + model2.unbundle(bundleData); + setTimeout(function() { + expect(model2.get('dogs.fido.name')).to.eql('Fido'); + done(); + }, 8); + }); + }); + }); + }); +}); diff --git a/test/mocha.opts b/test/mocha.opts deleted file mode 100644 index a4a98d415..000000000 --- a/test/mocha.opts +++ /dev/null @@ -1,4 +0,0 @@ ---compilers coffee:coffee-script/register ---reporter spec ---timeout 1200 ---bail diff --git a/test/util.js b/test/util.js index 880b59649..bc3c0eced 100644 --- a/test/util.js +++ b/test/util.js @@ -1,5 +1,5 @@ var util = require('util'); -var expect = require('expect.js'); +var expect = require('chai').expect; exports.expect = expect; @@ -19,17 +19,3 @@ exports.inspect = function(value, depth, showHidden) { if (showHidden == null) showHidden = true; console.log(util.inspect(value, {depth: depth, showHidden: showHidden})); }; - -expect.Assertion.prototype.NaN = function() { - this.assert(this.obj !== this.obj, - 'expected ' + util.inspect(this.obj) + ' to be NaN', - 'expected ' + util.inspect(this.obj) + ' to not be NaN' - ); -}; - -expect.Assertion.prototype.null = function() { - this.assert(this.obj == null, - 'expected ' + util.inspect(this.obj) + ' to be null or undefined', - 'expected ' + util.inspect(this.obj) + ' to not be null or undefined' - ); -}; diff --git a/test/util/util.js b/test/util/util.js new file mode 100644 index 000000000..2131af056 --- /dev/null +++ b/test/util/util.js @@ -0,0 +1,93 @@ +var expect = require('../util').expect; +var util = require('../../lib/util'); + +describe('util', function() { + describe('util.mergeInto', () => { + it('merges empty objects', () => { + var a = {}; + var b = {}; + expect(util.mergeInto(a, b)).to.eql({}); + }); + + it('merges an empty object with a populated object', () => { + var fn = function(x) { + return x++; + }; + var a = {}; + var b = {x: 's', y: [1, 3], fn: fn}; + expect(util.mergeInto(a, b)).to.eql({x: 's', y: [1, 3], fn: fn}); + }); + + it('merges a populated object with a populated object', () => { + var fn = function(x) { + return x++; + }; + var a = {x: 's', y: [1, 3], fn: fn}; + var b = {x: 7, z: {}}; + expect(util.mergeInto(a, b)).to.eql({x: 7, y: [1, 3], fn: fn, z: {}}); + // Merge should modify the first argument + expect(a).to.eql({x: 7, y: [1, 3], fn: fn, z: {}}); + // But not the second + expect(b).to.eql({x: 7, z: {}}); + }); + }); + + describe('promisify', () => { + it('wrapped functions return promise', async () => { + var targetFn = function(num, cb) { + setImmediate(() => { + cb(undefined, num); + }); + }; + var promisedFn = util.promisify(targetFn); + var promise = promisedFn(3); + expect(promise).to.be.instanceOf(Promise); + var result = await promise; + expect(result).to.equal(3); + }); + + it('wrapped functions throw errors passed to callback', async () => { + var targetFn = function(num, cb) { + setImmediate(() => { + cb(new Error(`Error ${num}`)); + }); + }; + var promisedFn = util.promisify(targetFn); + try { + await promisedFn(3); + fail('Expected promisedFn to reject, but it successfully resolved'); + } catch (error) { + expect(error).to.have.property('message', 'Error 3'); + } + }); + + it('wrapped functions throw on thrown error', async () => { + var targetFn = function(num) { + throw new Error(`Error ${num}`); + }; + var promisedFn = util.promisify(targetFn); + try { + await promisedFn(3); + fail('Expected promisedFn to reject, but it successfully resolved'); + } catch (error) { + expect(error).to.have.property('message', 'Error 3'); + } + }); + }); + + describe('castSegments', () => { + it('passes non-numeric strings as-is', () => { + const segments = ['foo', 'bar']; + const actual = util.castSegments(segments); + expect(actual).to.eql(['foo', 'bar']); + expect(actual).to.not.equal(segments); // args not mutated + }); + + it('ensures numeric path segments are returned as numbers', () => { + const segments = ['foo', '3', 3]; + const actual = util.castSegments(segments); + expect(actual).to.eql(['foo', 3, 3]); + expect(actual).to.not.equal(segments); // args not mutated + }); + }); +}); diff --git a/test/util/util.mocha.coffee b/test/util/util.mocha.coffee deleted file mode 100644 index f0f9baf56..000000000 --- a/test/util/util.mocha.coffee +++ /dev/null @@ -1,28 +0,0 @@ -{expect} = require '../util' -util = require '../../lib/util' - -describe 'util', -> - - describe 'util.mergeInto', -> - - it 'merges empty objects', -> - a = {} - b = {} - expect(util.mergeInto a, b).to.eql {} - - it 'merges an empty object with a populated object', -> - fn = (x) -> x++ - a = {} - b = x: 's', y: [1, 3], fn: fn - expect(util.mergeInto a, b).to.eql x: 's', y: [1, 3], fn: fn - - it 'merges a populated object with a populated object', -> - fn = (x) -> x++ - a = x: 's', y: [1, 3], fn: fn - b = x: 7, z: {} - expect(util.mergeInto a, b).to.eql x: 7, y: [1, 3], fn: fn, z: {} - - # Merge should modify the first argument - expect(a).to.eql x: 7, y: [1, 3], fn: fn, z: {} - # But not the second - expect(b).to.eql x: 7, z: {} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..f75922da7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowJs": true, + "ignoreDeprecations": "5.0", + "lib":[], + "module": "CommonJS", + "noImplicitUseStrict": true, + "outDir": "lib", + "target": "ES5", + "sourceMap": false, + "declaration": true, + "declarationMap": false, + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 000000000..f7968cc07 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,17 @@ +{ + "entryPoints": ["src/index.ts"], + "excludeNotDocumented": false, + "plugin":[ + "typedoc-plugin-mdn-links", + "typedoc-plugin-missing-exports", + "./typedocExcludeUnderscore.mjs" + ], + "out": "docs", + "visibilityFilters": { + "protected": false, + "private": false, + "inherited": true, + "external": false + }, + "excludePrivate": true + } \ No newline at end of file diff --git a/typedocExcludeUnderscore.mjs b/typedocExcludeUnderscore.mjs new file mode 100644 index 000000000..22d1b7087 --- /dev/null +++ b/typedocExcludeUnderscore.mjs @@ -0,0 +1,30 @@ +import { Converter, ReflectionFlag, ReflectionKind } from "typedoc"; +import camelCase from "camelcase"; + +/** + * @param {Readonly} app + */ +export function load(app) { + /** + * Create declaration event handler that sets symbols with underscore-prefixed names + * to private to exclude from generated documentation. + * + * Due to "partial class" style of code in use, otherwise private properties and methods - + * prefixed with underscore - are effectively declared public so they can be accessed in other + * files used to build class - e.g. Model. This marks anything prefixed with an underscore and + * no doc comment as private. + * + * @param {import('typedoc').Context} context + * @param {import('typedoc').DeclarationReflection} reflection + */ + function handleCreateDeclaration(context, reflection) { + if (!reflection.name.startsWith('_')) { + return; + } + if (!reflection.comment) { + reflection.setFlag(ReflectionFlag.Private); + } + } + + app.converter.on(Converter.EVENT_CREATE_DECLARATION, handleCreateDeclaration); +} \ No newline at end of file