diff --git a/TODO.md b/TODO.md index 62146d6..59f15a1 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,11 @@ ## sofa +## Operations + +* figure out when exactly `fastboot.deferRendering` +* wait until there are zero ops for 1-2(?) runloops + ### Relationship classes * paginated relationship helper `Relationship.extend({ paginated: paginated(...) })` @@ -40,8 +45,9 @@ ### other +* remove *-destroy.js * is it possible to provide `promise` prop for `PassiveRelationLoaderStateMixin`? -* `model.save()`, `model.delete()`, ... second call while 1st is pending should return the same promise +* `model.save()`, `model.delete()`, ... second call while 1st is pending should return the same promise. `internalModel.running().then...` * option to delete documents by saving with `_deleted:true` or delete with `{_deleted: true, type:..}` * per-database models (each database is initialized with model folder name which is returned by `store.databaseOptionsForIdentifier`) * embedded models (persisted as a `{ key: { model } }`) diff --git a/addon/couch.js b/addon/couch.js index dba7a67..2bd7653 100644 --- a/addon/couch.js +++ b/addon/couch.js @@ -2,6 +2,7 @@ import Ember from 'ember'; import CouchSession from './couch/couch-session'; import CouchChanges from './couch/couch-changes'; import CouchInternalChangesIdentity from './couch/couch-internal-changes-identity'; +import CouchOperations from './couch/couch-operations'; import CouchDestroy from './couch/couch-destroy'; const { @@ -20,6 +21,7 @@ export default Ember.Object.extend( CouchSession, CouchChanges, CouchInternalChangesIdentity, + CouchOperations, CouchDestroy, { documents: null, diff --git a/addon/couch/couch-operations.js b/addon/couch/couch-operations.js new file mode 100644 index 0000000..4a85c2d --- /dev/null +++ b/addon/couch/couch-operations.js @@ -0,0 +1,3 @@ +import forward from '../operations/forward-register-operation'; + +export default forward('store'); diff --git a/addon/database.js b/addon/database.js index 53c7bec..c5626a1 100644 --- a/addon/database.js +++ b/addon/database.js @@ -11,6 +11,7 @@ import DatabaseSecurity from './database/database-security'; import DatabaseShoebox from './database/database-shoebox'; import DatabaseChanges from './database/database-changes'; import DatabaseInternalChangesIdentity from './database/database-internal-changes-identity'; +import DatabaseOperations from './database/database-operations'; import DatabaseDestroy from './database/database-destroy'; export default Ember.Object.extend( @@ -25,6 +26,7 @@ export default Ember.Object.extend( DatabaseShoebox, DatabaseChanges, DatabaseInternalChangesIdentity, + DatabaseOperations, DatabaseDestroy, { identifier: null, diff --git a/addon/database/database-internal-model.js b/addon/database/database-internal-model.js index 860fbc3..4de39be 100644 --- a/addon/database/database-internal-model.js +++ b/addon/database/database-internal-model.js @@ -114,7 +114,7 @@ export default Ember.Mixin.create({ let documents = this.get('documents'); let resume; - return resolve().then(() => { + return this._registerOperation(resolve().then(() => { return this._onInternalModelWillSave(internal); }).then(() => { let doc = this._serializeInternalModelToDocument(internal, 'document'); @@ -135,7 +135,7 @@ export default Ember.Mixin.create({ resume(); } return reject(err); - }); + })); }, _onInternalModelLoading(internal) { @@ -194,12 +194,12 @@ export default Ember.Mixin.create({ this._onInternalModelLoading(internal); - return documents.load(docId).then(doc => { + return this._registerOperation(documents.load(docId).then(doc => { return this._onInternalModelLoaded(internal, doc); }, err => { this._onInternalModelLoadFailed(internal, err); return reject(err); - }); + })); }, _loadInternalModel(internal, opts) { @@ -217,16 +217,7 @@ export default Ember.Mixin.create({ let documents = this.get('documents'); let ids = A(array.map(internal => internal.docId)); - // TODO: chunk array in 300 ids per request - // let size = 1; - // let chunks = chunkArray(array, size); - // return allSettled(chunks.map(chunk => { - // return documents.all({ include_docs: true, keys: chunk.map(internal => internal.docId) }); - // })).then(blocks => { - // }).then(hash => { - // }); - - return documents.all({ include_docs: true, keys: ids }).then(json => { + return this._registerOperation(documents.all({ include_docs: true, keys: ids }).then(json => { let rows = json.rows; return allSettled(array.map((internal, idx) => { let row = rows[idx]; @@ -248,7 +239,7 @@ export default Ember.Mixin.create({ return reject(new Errors(rejected.map(rejection => rejection.reason))); } return array; - }); + })); }, _loadInternalModelForDocId(docId, opts) { @@ -257,9 +248,11 @@ export default Ember.Mixin.create({ return this._loadInternalModel(internal, opts); } - return this.get('documents').load(docId).then(doc => { + let documents = this.get('documents'); + + return this._registerOperation(documents.load(docId).then(doc => { return this._deserializeSavedDocumentToInternalModel(doc, null, false); - }); + })); }, _loadInternalModelForModelName(modelName, modelId, opts) { @@ -272,9 +265,11 @@ export default Ember.Mixin.create({ let definition = this._definitionForModelClass(modelClass); let docId = definition.docId(modelId); - return this.get('documents').load(docId).then(doc => { + let documents = this.get('documents'); + + return this._registerOperation(documents.load(docId).then(doc => { return this._deserializeSavedDocumentToInternalModel(doc, modelClass, false); - }); + })); }, _onInternalModelDeleted(internal, json) { @@ -300,18 +295,13 @@ export default Ember.Mixin.create({ _invokeInternalWillDeleteCallbacks(internal) { let model = internal.model; if(!model) { - return resolve(); + return; } - - return resolve().then(() => { - return model.willDelete(); - }); + return resolve(model.willDelete()); }, _onInternalModelWillDelete(internal) { - return resolve().then(() => { - return this._invokeInternalWillDeleteCallbacks(internal); - }); + return resolve(this._invokeInternalWillDeleteCallbacks(internal)); }, _onInternalModelDeleting(internal) { @@ -337,7 +327,7 @@ export default Ember.Mixin.create({ let rev = internal.rev; let documents = this.get('documents'); - return resolve().then(() => { + return this._registerOperation(resolve().then(() => { return this._onInternalModelWillDelete(internal); }).then(() => { this._onInternalModelDeleting(internal); @@ -347,7 +337,7 @@ export default Ember.Mixin.create({ return internal; }, err => { return this._onInternalModelDeleteFailed(internal, err); - }); + })); }, _expectedModelClassFromOpts(opts) { @@ -377,9 +367,10 @@ export default Ember.Mixin.create({ let optional = this._optionalFromOpts(opts); let documents = this.get('documents'); - return documents.view(ddoc, view, opts).then(json => { + + return this._registerOperation(documents.view(ddoc, view, opts).then(json => { return this._deserializeDocuments(A(json.rows).map(row => row.doc), expectedModelClass, optional); - }); + })); }, _internalModelMango(opts) { @@ -397,9 +388,10 @@ export default Ember.Mixin.create({ } let mango = this.get('documents.mango'); - return mango.find(opts).then(json => { + + return this._registerOperation(mango.find(opts).then(json => { return this._deserializeDocuments(json.docs, expectedModelClass, optional); - }); + })); }, _internalModelAll(opts) { @@ -409,9 +401,10 @@ export default Ember.Mixin.create({ let optional = this._optionalFromOpts(opts); let documents = this.get('documents'); - return documents.all(opts).then(json => { + + return this._registerOperation(documents.all(opts).then(json => { return this._deserializeDocuments(A(json.rows).map(row => row.doc), expectedModelClass, optional); - }); + })); }, // @@ -431,8 +424,8 @@ export default Ember.Mixin.create({ let ddoc = opts.ddoc; let selector = opts.selector; - let result = (type) => { - return (result) => { + let result = type => { + return result => { return { result, type }; }; }; diff --git a/addon/database/database-operations.js b/addon/database/database-operations.js new file mode 100644 index 0000000..4a85c2d --- /dev/null +++ b/addon/database/database-operations.js @@ -0,0 +1,3 @@ +import forward from '../operations/forward-register-operation'; + +export default forward('store'); diff --git a/addon/operations/forward-register-operation.js b/addon/operations/forward-register-operation.js new file mode 100644 index 0000000..fa4613e --- /dev/null +++ b/addon/operations/forward-register-operation.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default name => Ember.Mixin.create({ + + _registerOperation() { + return this.get(name)._registerOperation(...arguments); + } + +}); diff --git a/addon/operations/internal-operation.js b/addon/operations/internal-operation.js new file mode 100644 index 0000000..b481dab --- /dev/null +++ b/addon/operations/internal-operation.js @@ -0,0 +1,38 @@ +import Ember from 'ember'; + +const { + assert +} = Ember; + +const noop = () => {}; + +export default class InternalOperation { + + constructor(owner, promise) { + assert(`promise must have promise.then`, promise && promise.then); + this.owner = owner; + this.promise = promise; + this.isDone = false; + } + + set promise(promise) { + assert(`promise already set`, !this._promise); + this._promise = promise; + this._done = this._promise.then(noop, noop).finally(() => this._setDone()); + } + + get promise() { + return this._promise; + } + + get done() { + return this._done; + } + + _setDone() { + assert(`already done`, !this.isDone); + this.isDone = true; + this.owner._internalOperationDidFinish(this); + } + +} diff --git a/addon/operations/operations-mixin.js b/addon/operations/operations-mixin.js new file mode 100644 index 0000000..61cc61e --- /dev/null +++ b/addon/operations/operations-mixin.js @@ -0,0 +1,23 @@ +import Ember from 'ember'; +import { lookup } from '../util/computed'; + +const operations = () => lookup('sofa:operations'); + +export default Ember.Mixin.create({ + + operations: operations(), + + _registerOperation(promise) { + this.get('operations').register(promise); + return promise; + }, + + willDestroy() { + this._super(); + let ops = this.cacheFor('operations'); + if(ops) { + ops.destroy(); + } + } + +}); diff --git a/addon/operations/operations.js b/addon/operations/operations.js new file mode 100644 index 0000000..7081d75 --- /dev/null +++ b/addon/operations/operations.js @@ -0,0 +1,48 @@ +import Ember from 'ember'; +import InternalOperation from './internal-operation'; +import { array } from '../util/computed'; +import { next } from '../util/run'; + +const { + RSVP: { all } +} = Ember; + +const iteration = (owner, resolve, idx=0) => { + next().then(() => { + let promises = owner.promises(); + console.log(`operations.settle #${idx}: ${promises.length} promise(s)`); + if(promises.length === 0) { + resolve(); + return; + } + all(promises).then(() => iteration(owner, resolve, ++idx)); + }); +}; + +export default Ember.Object.extend({ + + internalOperations: array(), + + register(promise) { + let op = new InternalOperation(this, promise); + this.get('internalOperations').pushObject(op); + return op; + }, + + promises() { + return this.get('internalOperations').map(op => op.done); + }, + + wait() { + return all(this.promises()); + }, + + settle() { + return new Promise(resolve => iteration(this, resolve)); + }, + + _internalOperationDidFinish(op) { + this.get('internalOperations').removeObject(op); + } + +}); diff --git a/addon/session.js b/addon/session.js index 5c49e82..bc228fd 100644 --- a/addon/session.js +++ b/addon/session.js @@ -1,5 +1,6 @@ import Ember from 'ember'; import createStateMixin from './util/basic-state-mixin'; +import createForwardMixin from './operations/forward-register-operation'; import { array } from './util/computed'; const { @@ -13,7 +14,12 @@ const State = createStateMixin({ onDirty: [ 'name', 'password' ] }); -export default Ember.Object.extend(State, Ember.Evented, { +const ForwardRegisterOperation = createForwardMixin('couch'); + +export default Ember.Object.extend( + State, + ForwardRegisterOperation, + Ember.Evented, { couch: null, documents: oneWay('couch.documents.session'), @@ -45,25 +51,27 @@ export default Ember.Object.extend(State, Ember.Evented, { load() { this.onLoading(); - return this.get('documents').load().then(data => { + let documents = this.get('documents'); + return this._registerOperation(documents.load().then(data => { this.onLoaded(data.userCtx); return this; }, err => { this.onError(err); return reject(err); - }); + })); }, _save() { this.onSaving(); let { name, password } = this.getProperties('name', 'password'); - return this.get('documents').save(name, password).then(data => { + let documents = this.get('documents'); + return this._registerOperation(documents.save(name, password).then(data => { this.onSaved(data); return this; }, err => { this.onSaveError(err); return reject(err); - }); + })); }, save(name, password) { @@ -75,13 +83,14 @@ export default Ember.Object.extend(State, Ember.Evented, { delete() { this.onSaving(); - return this.get('documents').delete().then(() => { + let documents = this.get('documents'); + return this._registerOperation(documents.delete().then(() => { this.onDeleted(); return this; }, err => { this.onError(err); return reject(err); - }); + })); }, onDeleted() { diff --git a/addon/store.js b/addon/store.js index 8778bd1..3ca17c5 100644 --- a/addon/store.js +++ b/addon/store.js @@ -14,6 +14,7 @@ import StoreRelation from './store/store-relation'; import StoreChangesClass from './store/store-changes-class'; import StoreChanges from './store/store-changes'; import StoreShoebox from './store/store-shoebox'; +import StoreOperations from './store/store-operations'; const { Logger: { warn } @@ -34,7 +35,8 @@ export default Ember.Service.extend( StoreModelNames, StoreDestroy, StoreModelAttachments, - StoreShoebox, { + StoreShoebox, + StoreOperations, { find() { warn(this+'', '`find`', ...arguments); diff --git a/addon/store/store-operations.js b/addon/store/store-operations.js new file mode 100644 index 0000000..f143f85 --- /dev/null +++ b/addon/store/store-operations.js @@ -0,0 +1,4 @@ +import Ember from 'ember'; +import Mixin from '../operations/operations-mixin'; + +export default Ember.Mixin.create(Mixin); diff --git a/app/initializers/sofa-internal.js b/app/initializers/sofa-internal.js index e507f68..b3d2eb1 100644 --- a/app/initializers/sofa-internal.js +++ b/app/initializers/sofa-internal.js @@ -1,4 +1,5 @@ import StoreIdentifier from 'sofa/store-identifier'; +import Operations from 'sofa/operations/operations'; import Store from 'sofa/store'; import Couches from 'sofa/couches'; import Couch from 'sofa/couch'; @@ -33,6 +34,7 @@ export default { initialize(container) { container.register('sofa:store-identifier', StoreIdentifier); container.register('sofa:store', Store, { instantiate: false }); + container.register('sofa:operations', Operations, { instantiate: false }); container.register('sofa:couches', Couches, { instantiate: false }); container.register('sofa:couch', Couch, { instantiate: false }); container.register('sofa:session', Session, { instantiate: false }); diff --git a/app/instance-initializers/sofa-shoebox.js b/app/instance-initializers/sofa-shoebox.js index 0a3f86e..ffd75f5 100644 --- a/app/instance-initializers/sofa-shoebox.js +++ b/app/instance-initializers/sofa-shoebox.js @@ -23,6 +23,7 @@ export default { let store = app.lookup('service:store'); if(fastboot.get('isFastBoot')) { + fastboot.deferRendering(store.get('operations').settle().then(() => console.log('deferRendering done'))); shoebox.put(key, { get docs() { return store._createShoebox(); diff --git a/blueprints/ember-cli-sofa/index.js b/blueprints/ember-cli-sofa/index.js index bf3a93b..026c7c7 100644 --- a/blueprints/ember-cli-sofa/index.js +++ b/blueprints/ember-cli-sofa/index.js @@ -2,6 +2,6 @@ module.exports = { normalizeEntityName: function() { }, afterInstall: function() { - return this.addAddonToProject('ember-cli-couch', '1.0.0') + return this.addAddonToProject('ember-cli-couch', '1.0.2') } }; diff --git a/package.json b/package.json index 759193e..dd89c87 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,14 @@ "license": "MIT", "devDependencies": { "broccoli-asset-rev": "2.5.0", - "ember-cli": "2.13.3", + "ember-cli": "2.14.0", "ember-cli-app-version": "3.0.0", - "ember-cli-couch": "1.0.0", + "ember-cli-couch": "1.0.2", "ember-cli-dependency-checker": "2.0.1", - "ember-cli-eslint": "4.0.0", + "ember-cli-eslint": "4.1.0", "ember-cli-htmlbars": "2.0.2", "ember-cli-htmlbars-inline-precompile": "0.4.3", - "ember-cli-inject-live-reload": "1.6.1", + "ember-cli-inject-live-reload": "1.7.0", "ember-cli-less": "1.5.4", "ember-cli-qunit": "4.0.0", "ember-cli-release": "1.0.0-beta.2", @@ -40,10 +40,10 @@ "ember-cli-uglify": "1.2.0", "ember-disable-prototype-extensions": "1.1.2", "ember-export-application-global": "2.0.0", - "ember-fetch": "3.2.6", + "ember-fetch": "3.2.9", "ember-load-initializers": "1.0.0", - "ember-resolver": "4.1.0", - "ember-source": "2.13.3", + "ember-resolver": "4.3.0", + "ember-source": "2.14.1", "glob": "7.1.2", "gulp": "3.9.1", "gulp-compile-handlebars": "0.6.1", @@ -65,7 +65,7 @@ "relationships" ], "dependencies": { - "ember-cli-babel": "6.4.1" + "ember-cli-babel": "6.6.0" }, "ember-addon": { "configPath": "tests/dummy/config" diff --git a/tests/unit/database-operations-test.js b/tests/unit/database-operations-test.js new file mode 100644 index 0000000..75d8013 --- /dev/null +++ b/tests/unit/database-operations-test.js @@ -0,0 +1,57 @@ +import { configurations, registerModels, cleanup } from '../helpers/setup'; +import { Model, prefix } from 'sofa'; + +configurations(({ module, test, createStore }) => { + + let store; + let db; + + let Duck = Model.extend({ + id: prefix() + }); + + function flush() { + store = createStore(); + db = store.get('db.main'); + db.set('modelNames', [ 'duck' ]); + } + + module('database-operations', () => { + registerModels({ Duck }); + flush(); + return cleanup(store, [ 'main' ]); + }); + + test('store has operations', assert => { + let ops = db.get('store.operations'); + assert.ok(ops); + }); + + test('database operation is registered for model save', assert => { + let model = db.model('duck', { id: 'yellow' }); + let ops = db.get('store.operations'); + + let promise = model.save(); + assert.ok(ops.get('internalOperations.length') === 1); + + let op = ops.get('internalOperations.lastObject'); + assert.equal(op.owner, ops); + assert.equal(op.isDone, false); + assert.ok(op.promise); + + return promise.then(() => { + assert.ok(ops.get('internalOperations.length') === 0); + assert.ok(op.isDone); + }); + }); + + test('wait for all ops to finish', assert => { + let ops = db.get('store.operations'); + db.model('duck', { id: 'yellow' }).save(); + assert.ok(ops.get('internalOperations.length') === 1); + return ops.wait().then(() => { + assert.ok(ops.get('internalOperations.length') === 0); + }); + }); + +}); diff --git a/tests/unit/session-test.js b/tests/unit/session-test.js index f8611d6..3985339 100644 --- a/tests/unit/session-test.js +++ b/tests/unit/session-test.js @@ -1,4 +1,4 @@ -import { configurations, login, logout, admin } from '../helpers/setup'; +import { configurations, login, logout, admin, next } from '../helpers/setup'; configurations(({ module, test, createStore }) => { @@ -240,4 +240,16 @@ configurations(({ module, test, createStore }) => { }); }); + test('session has operation', assert => { + let ops = session.get('couch.store.operations'); + session.set('name', admin.name); + session.set('password', admin.password); + assert.equal(ops.get('internalOperations.length'), 0); + let promise = session.save(); + assert.equal(ops.get('internalOperations.length'), 1); + return promise.then(() => next()).then(() => { + assert.equal(ops.get('internalOperations.length'), 0); + }); + }); + }); diff --git a/tests/unit/store-operations-test.js b/tests/unit/store-operations-test.js new file mode 100644 index 0000000..63688d4 --- /dev/null +++ b/tests/unit/store-operations-test.js @@ -0,0 +1,67 @@ +import Ember from 'ember'; +import { configurations, cleanup, registerModels, next } from '../helpers/setup'; +import { Model, prefix, belongsTo, hasMany } from 'sofa'; + +const { + RSVP: { all } +} = Ember; + +configurations({ only: '1.6' }, ({ module, test, createStore }) => { + + let Duck = Model.extend({ + id: prefix(), + parent: belongsTo('duck', { inverse: 'kids' }), + kids: hasMany('duck', { inverse: 'parent', persist: false }) + }); + + let store; + let db; + + const flush = () => { + store = createStore(); + db = store.get('db.main'); + } + + module('store-operations', () => { + registerModels({ Duck }); + flush(); + return cleanup(store, [ 'main' ]); + }); + + test('exists', assert => { + let ops = store.get('operations'); + let db = store.get('db.main'); + db.model('duck', { id: 'yellow' }).save(); + db.get('couch.session').save('foo', 'bar'); + assert.ok(ops.get('internalOperations.length') === 2); + return ops.wait().then(() => { + assert.ok(ops.get('internalOperations.length') === 0); + }); + }); + + test('autoloads', assert => { + let yellow = db.model('duck', { id: 'yellow' }); + let orange = db.model('duck', { id: 'orange', parent: yellow }); + let green = db.model('duck', { id: 'green', parent: orange }); + let done = false; + return all([ yellow, orange, green ].map(model => model.save())).then(() => { + flush(); + + db.load('duck', 'green').then(green => { + return green.get('parent').load(); + }).then(orange => { + return orange.get('parent').load(); + }).then(yellow => { + assert.equal(yellow.get('id'), 'yellow'); + return next(); + }).then(() => { + done = true; + }); + + return store.get('operations').settle(); + }).then(() => { + assert.ok(done); + }); + }); + +});