feat(metadata): Switch to storing metadata on models

Summary:
 - Adds a class ModelWithMetadata which models can now extend from
 - Instances of this class can query metadata for a plugin via
   `obj.metadataForPluginId(pluginId)`
 - To observe changes on metadata it is sufficient to observe database changes on
   the model. e.g.:
   `DatabaseStore.findAll(Thread,
   [Thread.attributes.pluginMetadata.contains(pluginId)])`
 - To set metadata a new action has been created: Actions.setMetadata
 - Adds a helper observable in nylas-observables to query for models with
   metadata
 - Merges CreateModelTask and UpdateModelTask into SyncbackModelTask
 - Update SendDraftTask ans SynbackDraftTask to handle metadata changes

Test Plan: - Unit tests

Reviewers: drew, evan, bengotow

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2575
This commit is contained in:
Juan Tejada 2016-02-17 14:54:43 -08:00
parent 458b0fbe91
commit 0217e5a509
33 changed files with 802 additions and 1262 deletions

View file

@ -3,7 +3,7 @@ import EmojiActions from './emoji-actions'
const emoji = require('node-emoji');
class EmojiPicker extends React.Component {
static displayName = "EmojiPicker"
static displayName = "EmojiPicker";
static propTypes = {
emojiOptions: React.PropTypes.array,
selectedEmoji: React.PropTypes.string,

View file

@ -40,7 +40,7 @@ class EmojisComposerExtension extends ContenteditableExtension {
sel.focusOffset + triggerWord.length);
}
}
}
};
static toolbarComponentConfig = ({toolbarState}) => {
const sel = toolbarState.selectionSnapshot;
@ -62,14 +62,14 @@ class EmojisComposerExtension extends ContenteditableExtension {
}
}
return null;
}
};
static editingActions = () => {
return [{
action: EmojiActions.selectEmoji,
callback: EmojisComposerExtension._onSelectEmoji,
}]
}
};
static onKeyDown = ({editor, event}) => {
const sel = editor.currentSelection()
@ -104,7 +104,7 @@ class EmojisComposerExtension extends ContenteditableExtension {
actionArg: {emojiChar: emoji.get(selectedEmoji)}});
}
}
}
};
static _findEmojiOptions(sel) {
if (sel.anchorNode &&
@ -161,7 +161,7 @@ class EmojisComposerExtension extends ContenteditableExtension {
}
editor.insertText(emojiChar);
}
}
};
static _emojiPickerWidth(emojiOptions) {
let maxLength = 0;

View file

@ -100,7 +100,7 @@ class NylasLongConnection
@withCursor (cursor) =>
return if @state is NylasLongConnection.State.Ended
console.log("Delta Connection: Starting for account #{@_accountId}, token #{token}, with cursor #{cursor}")
options = url.parse("#{@_api.APIRoot}/delta/streaming?cursor=#{cursor}&exclude_folders=false")
options = url.parse("#{@_api.APIRoot}/delta/streaming?cursor=#{cursor}&exclude_folders=false&exclude_metadata=false")
options.auth = "#{token}:"
if @_api.APIRoot.indexOf('https') is -1

View file

@ -76,6 +76,14 @@ class NylasSyncWorkerPool
{create, modify, destroy} = @_clusterDeltas(deltas)
# Remove any metadata deltas. These have to be handled at the end, since metadata
# is stored within the object that it points to (which may not exist yet)
metadata = []
for deltas in [create, modify]
if deltas['metadata']
metadata = metadata.concat(_.values(deltas['metadata']))
delete deltas['metadata']
# Apply all the deltas to create objects. Gets promises for handling
# each type of model in the `create` hash, waits for them all to resolve.
create[type] = NylasAPI._handleModelResponse(_.values(dict)) for type, dict of create
@ -85,6 +93,8 @@ class NylasSyncWorkerPool
modify[type] = NylasAPI._handleModelResponse(_.values(dict)) for type, dict of modify
Promise.props(modify).then (modified) =>
Promise.all(@_handleDeltaMetadata(metadata)).then =>
# Now that we've persisted creates/updates, fire an action
# that allows other parts of the app to update based on new models
# (notifications)
@ -118,6 +128,15 @@ class NylasSyncWorkerPool
{create, modify, destroy}
_handleDeltaMetadata: (metadata) =>
metadata.map (metadatum) =>
klass = NylasAPI._apiObjectToClassMap[metadatum.object_type]
DatabaseStore.inTransaction (t) =>
t.find(klass, metadatum.object_id).then (model) ->
return Promise.resolve() unless model
model.applyPluginMetadata(metadatum.application_id, metadatum.value)
t.persistModel(model)
_handleDeltaDeletion: (delta) =>
klass = NylasAPI._apiObjectToClassMap[delta.object]
return unless klass

View file

@ -197,7 +197,6 @@ describe "NylasAPI", ->
"message": require('../src/flux/models/message')
"contact": require('../src/flux/models/contact')
"calendar": require('../src/flux/models/calendar')
"metadata": require('../src/flux/models/metadata')
verifyUpdateHappened = (klass, responseModels) ->
changedModels = DatabaseTransaction.prototype.persistModels.calls[0].args[0]

View file

@ -1,168 +0,0 @@
import {
Task,
NylasAPI,
APIError,
CreateModelTask,
DatabaseTransaction } from 'nylas-exports'
describe("CreateModelTask", () => {
beforeEach(() => {
spyOn(DatabaseTransaction.prototype, "persistModel")
});
it("constructs without error", () => {
const t = new CreateModelTask()
expect(t._rememberedToCallSuper).toBe(true)
});
describe("performLocal", () => {
it("throws if basic fields are missing", () => {
const t = new CreateModelTask()
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass.*/)
}
});
it("throws if `requiredFields` are missing", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {foo: "bar"}
const requiredFields = ["stuff"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass data field.*/)
}
});
it("throws if the model name can't be found", () => {
const accountId = "a123"
const modelName = "dne"
const endpoint = "/endpoint"
const data = {stuff: "bar"}
const requiredFields = ["stuff"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Couldn't find the class for.*/)
}
});
it("persists the new model properly", () => {
const persistFn = DatabaseTransaction.prototype.persistModel
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {value: "bar"}
const requiredFields = ["value"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
window.waitsForPromise(() => {
return t.performLocal().then(() => {
expect(persistFn).toHaveBeenCalled()
const model = persistFn.calls[0].args[0]
expect(model.constructor.name).toBe(modelName)
expect(model.value).toBe("bar")
});
});
});
});
describe("performRemote", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {value: "bar"}
beforeEach(() => {
this.task = new CreateModelTask({accountId, modelName, data, endpoint})
});
const performRemote = (fn) => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
return this.task.performRemote().then(fn)
});
});
}
it("makes a POST request to the Nylas API", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote(() => {
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.method).toBe("POST")
expect(opts.body.value).toBe("bar")
})
});
it("marks task success on API success", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote((status) => {
expect(status).toBe(Task.Status.Success)
})
});
it("retries on non permanent errors", () => {
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(new APIError({statusCode: 429}))
})
performRemote((status) => {
expect(status).toBe(Task.Status.Retry)
})
});
it("fails on permanent errors", () => {
const err = new APIError({statusCode: 500})
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(err)
})
performRemote((status) => {
expect(status).toEqual([Task.Status.Failed, err])
})
});
it("fails on other thrown errors", () => {
const err = new Error("foo")
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(err)
})
performRemote((status) => {
expect(status).toEqual([Task.Status.Failed, err])
})
});
});
describe("undo", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {key: "foo", value: "bar"}
beforeEach(() => {
this.task = new CreateModelTask({accountId, modelName, data, endpoint})
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate DestroyModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("DestroyModelTask")
expect(undoTask.clientId).toBe(this.task.model.clientId)
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -1,5 +1,6 @@
import {
Metadata,
Task,
Model,
NylasAPI,
DatabaseStore,
DestroyModelTask,
@ -7,7 +8,7 @@ import {
describe("DestroyModelTask", () => {
beforeEach(() => {
this.existingModel = new Metadata({key: "foo", value: "bar"})
this.existingModel = new Model()
this.existingModel.clientId = "local-123"
this.existingModel.serverId = "server-123"
spyOn(DatabaseTransaction.prototype, "unpersistModel")
@ -18,7 +19,7 @@ describe("DestroyModelTask", () => {
this.defaultArgs = {
clientId: "local-123",
accountId: "a123",
modelName: "Metadata",
modelName: "Model",
endpoint: "/endpoint",
}
});
@ -91,16 +92,13 @@ describe("DestroyModelTask", () => {
});
}
it("throws an error if the serverId is undefined", () => {
it("skips request if the serverId is undefined", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
this.task.serverId = null
try {
this.task.performRemote()
throw new Error("Should fail")
} catch (err) {
expect(err.message).toMatch(/^Need a serverId.*/)
}
return this.task.performRemote().then((status)=> {
expect(status).toEqual(Task.Status.Continue)
})
});
});
});
@ -115,26 +113,4 @@ describe("DestroyModelTask", () => {
})
});
});
describe("undo", () => {
beforeEach(() => {
this.task = new DestroyModelTask(this.defaultArgs)
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate CreateModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("CreateModelTask")
expect(undoTask.data).toBe(this.task.oldModel)
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -69,14 +69,11 @@ describe "SendDraftTask", ->
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
options.success?(@response)
Promise.resolve(@response)
spyOn(DBt, 'unpersistModel').andCallFake (draft) ->
Promise.resolve()
spyOn(DBt, 'persistModel').andCallFake (draft) ->
Promise.resolve()
spyOn(DBt, 'unpersistModel').andReturn Promise.resolve()
spyOn(DBt, 'persistModel').andReturn Promise.resolve()
spyOn(SoundRegistry, "playSound")
spyOn(Actions, "postNotification")
spyOn(Actions, "sendDraftSuccess")
spyOn(NylasEnv, "reportError")
sharedTests = =>
it "makes a send request with the correct data", ->
@ -115,14 +112,11 @@ describe "SendDraftTask", ->
expect(DBt.persistModel.mostRecentCall.args[0].draft).toEqual(false)
it "should notify the draft was sent", ->
waitsForPromise => @task.performRemote().then =>
waitsForPromise =>
@task.performRemote().then =>
args = Actions.sendDraftSuccess.calls[0].args[0]
expect(args.draftClientId).toBe @draft.clientId
it "get an object back on success", ->
waitsForPromise => @task.performRemote().then =>
args = Actions.sendDraftSuccess.calls[0].args[0]
it "should play a sound", ->
spyOn(NylasEnv.config, "get").andReturn true
waitsForPromise => @task.performRemote().then ->
@ -143,6 +137,7 @@ describe "SendDraftTask", ->
it "notifies of a permanent error of misc error types", ->
## DB error
thrownError = null
spyOn(NylasEnv, "reportError")
jasmine.unspy(DBt, "persistModel")
spyOn(DBt, "persistModel").andCallFake =>
thrownError = new Error('db error')
@ -200,6 +195,7 @@ describe "SendDraftTask", ->
it "notifies of a permanent error on 500 errors", ->
thrownError = new APIError(statusCode: 500, body: "err")
spyOn(NylasEnv, "reportError")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
@ -209,6 +205,7 @@ describe "SendDraftTask", ->
it "notifies us and users of a permanent error on 400 errors", ->
thrownError = new APIError(statusCode: 400, body: "err")
spyOn(NylasEnv, "reportError")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
@ -225,6 +222,7 @@ describe "SendDraftTask", ->
describe "checking the promise chain halts on errors", ->
beforeEach ->
spyOn(NylasEnv, 'reportError')
spyOn(@task,"_sendAndCreateMessage").andCallThrough()
spyOn(@task,"_deleteRemoteDraft").andCallThrough()
spyOn(@task,"_onSuccess").andCallThrough()
@ -240,28 +238,44 @@ describe "SendDraftTask", ->
thrownError = new APIError(statusCode: 500, body: "err")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
waitsForPromise =>
@task.performRemote().then (status) =>
@expectBlockedChain()
it "halts on 400s", ->
thrownError = new APIError(statusCode: 400, body: "err")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
waitsForPromise =>
@task.performRemote().then (status) =>
@expectBlockedChain()
it "halts and retries on not permanent error codes", ->
thrownError = new APIError(statusCode: 409, body: "err")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise =>
@task.performRemote().then (status) =>
@expectBlockedChain()
it "halts on other errors", ->
thrownError = new Error("oh no")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
waitsForPromise =>
@task.performRemote().then (status) =>
@expectBlockedChain()
it "dosn't halt on success", ->
it "doesn't halt on success", ->
# Don't spy reportError to make sure to fail the test on unexpected
# errors
jasmine.unspy(NylasEnv, 'reportError')
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
options.success?(@response)
Promise.resolve(@response)
waitsForPromise => @task.performRemote().then (status) =>
waitsForPromise =>
@task.performRemote().then (status) =>
expect(status).toBe Task.Status.Success
expect(@task._sendAndCreateMessage).toHaveBeenCalled()
expect(@task._deleteRemoteDraft).toHaveBeenCalled()
expect(@task._onSuccess).toHaveBeenCalled()
@ -281,22 +295,23 @@ describe "SendDraftTask", ->
@task = new SendDraftTask(@draft)
@calledBody = "ERROR: The body wasn't included!"
spyOn(DatabaseStore, "findBy").andCallFake =>
include: (body) =>
@calledBody = body
Promise.resolve(@draft)
sharedTests()
it "can complete a full performRemote", -> waitsForPromise =>
it "can complete a full performRemote", ->
waitsForPromise =>
@task.performRemote().then (status) ->
expect(status).toBe Task.Status.Success
it "shouldn't attempt to delete a draft", -> waitsForPromise =>
@task._deleteRemoteDraft().then =>
it "shouldn't attempt to delete a draft", ->
waitsForPromise =>
@task._deleteRemoteDraft(@draft).then =>
expect(NylasAPI.makeRequest).not.toHaveBeenCalled()
it "should locally convert the draft to a message on send", ->
waitsForPromise => @task.performRemote().then =>
waitsForPromise =>
@task.performRemote().then =>
expect(DBt.persistModel).toHaveBeenCalled()
model = DBt.persistModel.calls[0].args[0]
expect(model.clientId).toBe @draft.clientId
@ -321,9 +336,6 @@ describe "SendDraftTask", ->
@task = new SendDraftTask(@draft)
@calledBody = "ERROR: The body wasn't included!"
spyOn(DatabaseStore, "findBy").andCallFake =>
then: -> throw new Error("You must include the body!")
include: (body) =>
@calledBody = body
Promise.resolve(@draft)
sharedTests()
@ -335,7 +347,8 @@ describe "SendDraftTask", ->
it "should make a request to delete a draft", ->
@task.performLocal()
waitsForPromise => @task._deleteRemoteDraft().then =>
waitsForPromise =>
@task._deleteRemoteDraft(@draft).then =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
expect(NylasAPI.makeRequest.callCount).toBe 1
req = NylasAPI.makeRequest.calls[0].args[0]
@ -350,7 +363,9 @@ describe "SendDraftTask", ->
Promise.reject(new APIError(body: "Boo", statusCode: 500))
@task.performLocal()
waitsForPromise => @task._deleteRemoteDraft().then =>
waitsForPromise =>
@task._deleteRemoteDraft(@draft)
.then =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
expect(NylasAPI.makeRequest.callCount).toBe 1
.catch =>

View file

@ -2,6 +2,7 @@ _ = require 'underscore'
{DatabaseTransaction,
SyncbackDraftTask,
SyncbackMetadataTask,
DatabaseStore,
AccountStore,
TaskQueue,
@ -32,7 +33,7 @@ remoteDraft = -> new Message _.extend {}, testData, {clientId: "local-id", serve
describe "SyncbackDraftTask", ->
beforeEach ->
spyOn(AccountStore, "accountForEmail").andCallFake (email) ->
return new Account(clientId: 'local-abc123', serverId: 'abc123')
return new Account(clientId: 'local-abc123', serverId: 'abc123', emailAddress: email)
spyOn(DatabaseStore, "run").andCallFake (query) ->
clientId = query.matcherValueForModelKey('clientId')
@ -41,8 +42,8 @@ describe "SyncbackDraftTask", ->
else if clientId is "missingDraftId" then Promise.resolve()
else return Promise.resolve()
spyOn(DatabaseTransaction.prototype, "persistModel").andCallFake ->
Promise.resolve()
spyOn(DatabaseTransaction.prototype, "persistModel").andCallFake (draft) ->
Promise.resolve(draft)
describe "queueing multiple tasks", ->
beforeEach ->
@ -148,6 +149,73 @@ describe "SyncbackDraftTask", ->
options = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(options.returnsModel).toBe(false)
it "should not save metadata associated to the draft when the draft has been already saved to the api", ->
draft = remoteDraft()
draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
task = new SyncbackDraftTask(draft.clientId)
spyOn(task, 'getLatestLocalDraft').andReturn Promise.resolve(draft)
spyOn(Actions, 'queueTask')
waitsForPromise =>
task.updateLocalDraft(draft).then =>
expect(Actions.queueTask).not.toHaveBeenCalled()
it "should save metadata associated to the draft when the draft is syncbacked for the first time", ->
draft = localDraft()
draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
task = new SyncbackDraftTask(draft.clientId)
spyOn(task, 'getLatestLocalDraft').andReturn Promise.resolve(draft)
spyOn(Actions, 'queueTask')
waitsForPromise =>
task.updateLocalDraft(draft).then =>
metadataTask = Actions.queueTask.mostRecentCall.args[0]
expect(metadataTask instanceof SyncbackMetadataTask).toBe true
expect(metadataTask.clientId).toEqual draft.clientId
expect(metadataTask.modelClassName).toEqual 'Message'
expect(metadataTask.pluginId).toEqual 1
describe 'when `from` value does not match the account associated to the draft', ->
beforeEach ->
@serverId = 'remote123'
@draft = remoteDraft()
@draft.serverId = 'remote123'
@draft.from = [{email: 'another@email.com'}]
@task = new SyncbackDraftTask(@draft.clientId)
jasmine.unspy(AccountStore, 'accountForEmail')
spyOn(AccountStore, "accountForEmail").andReturn {id: 'other-account'}
spyOn(Actions, "queueTask")
spyOn(@task, 'getLatestLocalDraft').andReturn Promise.resolve(@draft)
it "should delete the remote draft if it was already saved", ->
waitsForPromise =>
@task.checkDraftFromMatchesAccount(@draft).then =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
params = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(params.method).toEqual "DELETE"
expect(params.path).toEqual "/drafts/#{@serverId}"
it "should change the accountId and clear server fields", ->
waitsForPromise =>
@task.checkDraftFromMatchesAccount(@draft).then (updatedDraft) =>
expect(updatedDraft.serverId).toBeUndefined()
expect(updatedDraft.version).toBeUndefined()
expect(updatedDraft.threadId).toBeUndefined()
expect(updatedDraft.replyToMessageId).toBeUndefined()
expect(updatedDraft.accountId).toEqual 'other-account'
it "should syncback any metadata associated with the original draft", ->
@draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
@task = new SyncbackDraftTask(@draft.clientId)
spyOn(@task, 'getLatestLocalDraft').andReturn Promise.resolve(@draft)
spyOn(@task, 'saveDraft').andCallFake (d) -> Promise.resolve(d)
waitsForPromise =>
@task.performRemote().then =>
metadataTask = Actions.queueTask.mostRecentCall.args[0]
expect(metadataTask instanceof SyncbackMetadataTask).toBe true
expect(metadataTask.clientId).toEqual @draft.clientId
expect(metadataTask.modelClassName).toEqual 'Message'
expect(metadataTask.pluginId).toEqual 1
describe "When the api throws errors", ->
stubAPI = (code, method) ->
spyOn(NylasAPI, "makeRequest").andCallFake (opts) ->

View file

@ -2,23 +2,22 @@ import {
Task,
NylasAPI,
APIError,
Metadata,
Model,
DatabaseStore,
SyncbackModelTask,
DatabaseTransaction } from 'nylas-exports'
class TestTask extends SyncbackModelTask {
getModelConstructor() {
return Metadata
return Model
}
}
describe("SyncbackModelTask", () => {
beforeEach(() => {
this.testModel = new Metadata({accountId: 'account-123'})
this.testModel = new Model({accountId: 'account-123'})
spyOn(DatabaseTransaction.prototype, "persistModel")
spyOn(DatabaseStore, "findBy")
.andReturn(Promise.resolve(this.testModel));
spyOn(DatabaseStore, "findBy").andReturn(Promise.resolve(this.testModel));
spyOn(NylasEnv, "reportError")
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve({
@ -86,14 +85,14 @@ describe("SyncbackModelTask", () => {
it("gets the correct path and method for existing objects", () => {
jasmine.unspy(DatabaseStore, "findBy")
const serverModel = new Metadata({localId: 'local-123', serverId: 'server-123'})
const serverModel = new Model({clientId: 'local-123', serverId: 'server-123'})
spyOn(DatabaseStore, "findBy").andReturn(Promise.resolve(serverModel));
spyOn(this.task, "getPathAndMethod").andCallThrough();
spyOn(this.task, "getRequestData").andCallThrough();
performRemote(() => {
expect(this.task.getPathAndMethod).toHaveBeenCalled()
expect(this.task.getRequestData).toHaveBeenCalled()
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.path).toBe("/test/server-123")
expect(opts.method).toBe("PUT")
@ -101,10 +100,10 @@ describe("SyncbackModelTask", () => {
});
it("gets the correct path and method for new objects", () => {
spyOn(this.task, "getPathAndMethod").andCallThrough();
spyOn(this.task, "getRequestData").andCallThrough();
performRemote(() => {
expect(this.task.getPathAndMethod).toHaveBeenCalled()
expect(this.task.getRequestData).toHaveBeenCalled()
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.path).toBe("/test")
expect(opts.method).toBe("POST")
@ -114,21 +113,21 @@ describe("SyncbackModelTask", () => {
it("lets tasks override path and method", () => {
class TaskMethodAndPath extends SyncbackModelTask {
getModelConstructor() {
return Metadata
return Model
}
getPathAndMethod = () => {
getRequestData = () => {
return {
path: `/override`,
method: "DELETE",
}
}
};
}
const task = new TaskMethodAndPath({clientId: 'local-123'});
spyOn(task, "getPathAndMethod").andCallThrough();
spyOn(task, "getRequestData").andCallThrough();
spyOn(task, "getModelConstructor").andCallThrough()
window.waitsForPromise(() => {
return task.performRemote().then(() => {
expect(task.getPathAndMethod).toHaveBeenCalled()
expect(task.getRequestData).toHaveBeenCalled()
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.path).toBe("/override")
expect(opts.method).toBe("DELETE")

View file

@ -1,143 +0,0 @@
import {
Metadata,
NylasAPI,
DatabaseStore,
UpdateModelTask,
DatabaseTransaction} from 'nylas-exports'
describe("UpdateModelTask", () => {
beforeEach(() => {
this.existingModel = new Metadata({key: "foo", value: "bar"})
this.existingModel.clientId = "local-123"
this.existingModel.serverId = "server-123"
spyOn(DatabaseTransaction.prototype, "persistModel")
spyOn(DatabaseStore, "findBy").andCallFake(() => {
return Promise.resolve(this.existingModel)
})
this.defaultArgs = {
clientId: "local-123",
newData: {value: "baz"},
accountId: "a123",
modelName: "Metadata",
endpoint: "/endpoint",
}
});
it("constructs without error", () => {
const t = new UpdateModelTask()
expect(t._rememberedToCallSuper).toBe(true)
});
describe("performLocal", () => {
it("throws if basic fields are missing", () => {
const t = new UpdateModelTask()
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass.*/)
}
});
it("throws if the model name can't be found", () => {
this.defaultArgs.modelName = "dne"
const t = new UpdateModelTask(this.defaultArgs)
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Couldn't find the class for.*/)
}
});
it("throws if it can't find the object", () => {
jasmine.unspy(DatabaseStore, "findBy")
spyOn(DatabaseStore, "findBy").andCallFake(() => {
return Promise.resolve(null)
})
const t = new UpdateModelTask(this.defaultArgs)
window.waitsForPromise(() => {
return t.performLocal().then(() => {
throw new Error("Shouldn't succeed")
}).catch((err) => {
expect(err.message).toMatch(/^Couldn't find the model with clientId.*/)
});
});
});
it("persists the new existing model properly", () => {
const persistFn = DatabaseTransaction.prototype.persistModel
const t = new UpdateModelTask(this.defaultArgs)
window.waitsForPromise(() => {
return t.performLocal().then(() => {
expect(persistFn).toHaveBeenCalled()
const model = persistFn.calls[0].args[0]
expect(model).toBe(this.existingModel)
});
});
});
});
describe("performRemote", () => {
beforeEach(() => {
this.task = new UpdateModelTask(this.defaultArgs)
});
const performRemote = (fn) => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
return this.task.performRemote().then(fn)
});
});
}
it("throws an error if the serverId is undefined", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
this.task.serverId = null
try {
this.task.performRemote()
throw new Error("Should fail")
} catch (err) {
expect(err.message).toMatch(/^Need a serverId.*/)
}
});
});
});
it("makes a PUT request to the Nylas API", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote(() => {
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.method).toBe("PUT")
expect(opts.path).toBe("/endpoint/server-123")
expect(opts.body).toBe(this.defaultArgs.newData)
expect(opts.accountId).toBe(this.defaultArgs.accountId)
})
});
});
describe("undo", () => {
beforeEach(() => {
this.task = new UpdateModelTask(this.defaultArgs)
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate UpdateModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("UpdateModelTask")
expect(undoTask.newData).toBe(this.task.oldModel)
expect(undoTask.newData.value).toBe("bar")
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -16,6 +16,8 @@ class Bar extends Foo
@moreStuff = stuff
@method(stuff)
describe 'Utils', ->
describe "registeredObjectReviver / registeredObjectReplacer", ->
beforeEach ->
@testThread = new Thread
@ -28,7 +30,7 @@ describe "registeredObjectReviver / registeredObjectReplacer", ->
subject: 'Test 1234'
it "should serialize and de-serialize models correctly", ->
expectedString = '[{"client_id":"local-1","account_id":"1","subject":"Test 1234","participants":[{"client_id":"local-a","account_id":"1","name":"Juan","email":"juan@nylas.com","thirdPartyData":{},"id":"local-a"},{"client_id":"local-b","account_id":"1","name":"Ben","email":"ben@nylas.com","thirdPartyData":{},"id":"local-b"}],"id":"local-1","__constructorName":"Thread"}]'
expectedString = '[{"client_id":"local-1","account_id":"1","metadata":[],"subject":"Test 1234","participants":[{"client_id":"local-a","account_id":"1","name":"Juan","email":"juan@nylas.com","thirdPartyData":{},"id":"local-a"},{"client_id":"local-b","account_id":"1","name":"Ben","email":"ben@nylas.com","thirdPartyData":{},"id":"local-b"}],"id":"local-1","__constructorName":"Thread"}]'
jsonString = JSON.stringify([@testThread], Utils.registeredObjectReplacer)
expect(jsonString).toEqual(expectedString)
@ -38,7 +40,7 @@ describe "registeredObjectReviver / registeredObjectReplacer", ->
it "should re-inflate Models in places they're not explicitly declared types", ->
b = new JSONBlob({id: "local-ThreadsToProcess", json: [@testThread]})
jsonString = JSON.stringify(b, Utils.registeredObjectReplacer)
expectedString = '{"client_id":"local-ThreadsToProcess","server_id":"local-ThreadsToProcess","json":[{"client_id":"local-1","account_id":"1","subject":"Test 1234","participants":[{"client_id":"local-a","account_id":"1","name":"Juan","email":"juan@nylas.com","thirdPartyData":{},"id":"local-a"},{"client_id":"local-b","account_id":"1","name":"Ben","email":"ben@nylas.com","thirdPartyData":{},"id":"local-b"}],"id":"local-1","__constructorName":"Thread"}],"id":"local-ThreadsToProcess","__constructorName":"JSONBlob"}'
expectedString = '{"client_id":"local-ThreadsToProcess","server_id":"local-ThreadsToProcess","json":[{"client_id":"local-1","account_id":"1","metadata":[],"subject":"Test 1234","participants":[{"client_id":"local-a","account_id":"1","name":"Juan","email":"juan@nylas.com","thirdPartyData":{},"id":"local-a"},{"client_id":"local-b","account_id":"1","name":"Ben","email":"ben@nylas.com","thirdPartyData":{},"id":"local-b"}],"id":"local-1","__constructorName":"Thread"}],"id":"local-ThreadsToProcess","__constructorName":"JSONBlob"}'
expect(jsonString).toEqual(expectedString)
revived = JSON.parse(jsonString, Utils.registeredObjectReviver)

View file

@ -1,202 +0,0 @@
import _ from 'underscore'
import Rx from 'rx-lite'
import {
Model,
Actions,
Metadata,
DatabaseStore,
SyncbackMetadataTask} from 'nylas-exports'
/**
* CloudStorage lets you associate metadata with any Nylas API object.
*
* That associated data is automatically stored in the cloud and synced
* with the local database.
*
* You can also get associated data and live-subscribe to associated data.
*
* On the Nylas API server this is backed by the `/metadata` endpoint.
*
* It is automatically locally replicated and synced with the `Metadata`
* Database table.
*
* Every interaction with the metadata service is automatically scoped
* by both your unique Plugin ID.
*
* You must asscoiate with pre-existing objects that inherit from `Model`.
* We will extract the appropriate `accountId` from those objects to
* correctly associate your data.
*
* You can observe one or more objects and treat them as live,
* asynchronous event streams. We use the `Rx.Observable` interface for
* this.
*
* Under the hood the observables are hooked up to our delta streaming
* endpoint via Database events.
*
* ## Example Usage:
*
* In /My-Plugin/lib/main.es6
*
* ```
* activate(localState, cloudStorage) {
* DatabaseStore.findBy(Thread, "some_thread_id").then((thread) => {
*
* const data = { foo: "bar" }
* cloudStorage.associateMetadata({objects: [thread], data: data})
*
* cloudStorage.getMetadata({objects: [thread]}).then((metadata) => {
* console.log(metadata[0]);
* });
*
* const observer = cloudStorage.observeMetadata({objects: [thread]})
* this.subscription = observer.subscribe((newMetadata) => {
* console.log("New Metadata!", newMetadata[0])
* });
* });
* }
*
* deactivate() {
* this.subscription.dispose()
* }
* ```
*
* A CloudStorage instance is a pre-scoped helper to allow plugins to
* interface with a server-side key-value store.
*
* When you `associateMetadata` we generate a `SyncbackMetadataTask`
* pre-scoped with the `pluginId`.
* @class CloudStorage
*/
export default class CloudStorage {
// You never need to instantiate new instances of `CloudStorage`. The
// Nylas package manager will take care of this.
constructor(pluginId) {
this.pluginId = pluginId
this.applicationId = pluginId
}
/**
* Associates one or more {Model}-inheriting objects with arbitrary
* `data`. This is automatically persisted to both the `Database` and a
* the `/metadata` endpoint on the Nylas API
*
* @param {object} props - props for `associateMetadata`
* @param {array} props.objects - an array of one or more objects to
* associate with the same metadata. These are objects pulled out of the
* Database.
* @param {object} props.data - arbitray JSON-serializable data.
*/
associateMetadata({objects, data}) {
const objectsToAssociate = this._resolveObjects(objects)
DatabaseStore.findAll(Metadata,
{objectId: _.pluck(objectsToAssociate, "id")})
.then((existingMetadata = []) => {
const metadataByObjectId = {}
for (const m of existingMetadata) {
metadataByObjectId[m.objectId] = m
}
const metadata = []
for (const objectToAssociate of objectsToAssociate) {
let metadatum = metadataByObjectId[objectToAssociate.id]
if (!metadatum) {
metadatum = this._newMetadataObject(objectToAssociate)
} else {
metadatum = this._validateMetadatum(metadatum, objectToAssociate)
}
metadatum.value = data
metadata.push(metadatum)
}
return DatabaseStore.inTransaction((t) => {
return t.persistModels(metadata).then(() => {
return this._syncbackMetadata(metadata)
})
});
})
}
/**
* Get the metadata associated with one or more objects.
*
* @param {object} props - props for `getMetadata`
* @param {array} props.objects - an array of one or more objects to
* load metadata for (if there is any)
* @returns Promise that resolves to an array of zero or more matching
* {Metadata} objects.
*/
getMetadata({objects}) {
const associatedObjects = this._resolveObjects(objects)
return DatabaseStore.findAll(Metadata,
{objectId: _.pluck(associatedObjects, "id")})
}
/**
* Observe the metadata on a set of objects via an RX.Observable
*
* @param {object} props - props for `getMetadata`
* @param {array} props.objects - an array of one or more objects to
* load metadata for (if there is any)
* @returns Rx.Observable object that you can call `subscribe` on to
* subscribe to any changes on the matching query. The onChange callback
* you pass to subscribe will be passed an array of zero or more
* matching {Metadata} objects.
*/
observeMetadata({objects}) {
const associatedObjects = this._resolveObjects(objects)
const q = DatabaseStore.findAll(Metadata,
{objectId: _.pluck(associatedObjects, "id")})
return Rx.Observable.fromQuery(q)
}
_syncbackMetadata(metadata) {
for (const metadatum of metadata) {
const task = new SyncbackMetadataTask({
clientId: metadatum.clientId,
});
Actions.queueTask(task)
}
}
_newMetadataObject(objectToAssociate) {
return new Metadata({
applicationId: this.applicationId,
objectType: this._typeFromObject(objectToAssociate),
objectId: objectToAssociate.id,
accountId: objectToAssociate.accountId,
});
}
_validateMetadatum(metadatum, objectToAssociate) {
const toMatch = {
applicationId: this.applicationId === metadatum.applicationId,
objectType: this._typeFromObject(objectToAssociate) === metadatum.objectType,
objectId: objectToAssociate.id === metadatum.objectId,
accountId: objectToAssociate.accountId === metadatum.accountId,
}
if (_.every(toMatch, (match) => {return match})) {
return metadatum
}
NylasEnv.reportError(new Error(`Metadata object ${metadatum.id} doesn't match data for associated object ${objectToAssociate.id}. Automatically correcting to match.`, toMatch))
const json = this._newMetadataObject(objectToAssociate).toJSON()
metadatum.fromJSON(json)
return metadatum
}
_typeFromObject(object) {
return object.constructor.name.toLowerCase()
}
_resolveObjects(objects) {
const isModel = (obj) => {return obj instanceof Model}
if (isModel(objects)) {
return [objects]
} else if (_.isArray(objects) && objects.length > 0 && _.every(objects, isModel)) {
return objects
}
throw new Error("Must pass one or more `Model` objects to associate")
}
}

View file

@ -75,6 +75,13 @@ class Actions
@didPassivelyReceiveNewModels: ActionScopeGlobal
@downloadStateChanged: ActionScopeGlobal
###
Public: Fired when a draft is successfully sent
*Scope: Global*
Recieves the clientId of the message that was sent
###
@sendDraftSuccess: ActionScopeGlobal
@sendToAllWindows: ActionScopeGlobal
@draftSendingFailed: ActionScopeGlobal
@ -503,6 +510,16 @@ class Actions
@deleteMailRule: ActionScopeWindow
@disableMailRule: ActionScopeWindow
###
Public: Set metadata for a specified model and pluginId.
*Scope: Window*
Receives an {Model} or {Array} of {Model}s, a plugin id, and an Object that
represents the metadata value.
###
@setMetadata: ActionScopeWindow
# Read the actions we declared on the dummy Actions object above
# and translate them into Reflux Actions

View file

@ -1,4 +1,4 @@
Model = require './model'
ModelWithMetadata = require './model-with-metadata'
Attributes = require '../attributes'
_ = require 'underscore'
@ -27,9 +27,9 @@ This class also inherits attributes from {Model}
Section: Models
###
class Account extends Model
class Account extends ModelWithMetadata
@attributes: _.extend {}, Model.attributes,
@attributes: _.extend {}, ModelWithMetadata.attributes,
'name': Attributes.String
modelKey: 'name'

View file

@ -3,12 +3,12 @@ moment = require 'moment'
File = require './file'
Utils = require './utils'
Model = require './model'
Event = require './event'
Category = require './category'
Contact = require './contact'
Attributes = require '../attributes'
AccountStore = require '../stores/account-store'
ModelWithMetadata = require './model-with-metadata'
###
Public: The Message model represents a Message object served by the Nylas Platform API.
@ -66,9 +66,9 @@ This class also inherits attributes from {Model}
Section: Models
###
class Message extends Model
class Message extends ModelWithMetadata
@attributes: _.extend {}, Model.attributes,
@attributes: _.extend {}, ModelWithMetadata.attributes,
'to': Attributes.Collection
modelKey: 'to'

View file

@ -1,60 +0,0 @@
import Model from './model'
import Attributes from '../attributes'
/**
Cloud-persisted data that is associated with a single Nylas API object
(like a `Thread`, `Message`, or `Account`).
Each Nylas API object can have exactly one `Metadata` object associated
with it. If you update the metadata object on an existing associated
Nylas API object, it will override the previous `value`
*/
export default class Metadata extends Model {
static attributes = Object.assign({}, Model.attributes, {
/*
The unique ID of the plugin that owns this Metadata item. The Nylas
N1 Database holds the Metadata objects for many of its installed
plugins.
*/
applicationId: Attributes.String({
queryable: true,
modelKey: 'applicationId',
jsonKey: 'application_id',
}),
/*
The type of Nylas API object this Metadata item is associated with.
Should be the lowercase singular classname of an object like
`thread` or `message`, or `account`
*/
objectType: Attributes.String({
queryable: true,
modelKey: 'objectType',
jsonKey: 'object_type',
}),
// The unique public ID of the Nylas API object.
objectId: Attributes.String({
queryable: true,
modelKey: 'objectId',
jsonKey: 'object_id',
}),
/*
The current version of this `Metadata` object. Note that since Nylas
API objects can only have exactly one `Metadata` object attached to
it, any action preformed on the `Metadata` of a Nylas API object
will override the existing object and bump the `version`.
*/
version: Attributes.Number({
modelKey: 'version',
jsonKey: 'version',
}),
// A generic value that can be any string-serializable object.
value: Attributes.Object({
modelKey: 'value',
jsonKey: 'value',
}),
});
}

View file

@ -0,0 +1,97 @@
import Model from './model'
import Attributes from '../attributes'
/**
Cloud-persisted data that is associated with a single Nylas API object
(like a `Thread`, `Message`, or `Account`).
*/
class PluginMetadata extends Model {
static attributes = {
pluginId: Attributes.String({
modelKey: 'pluginId',
}),
version: Attributes.Number({
modelKey: 'version',
}),
value: Attributes.Object({
modelKey: 'value',
}),
};
constructor(...args) {
super(...args)
this.version = this.version || 0;
}
get id() {
return this.pluginId
}
set id(pluginId) {
this.pluginId = pluginId
}
}
/**
Plugins can attach arbitrary JSON data to any model that subclasses
ModelWithMetadata, like {{Thread}} or {{Message}}. You must get and set
metadata using your plugin's ID, and any metadata you set overwrites the
metadata previously on the object for the given plugin id.
Reading the metadata of other plugins is discouraged and may become impossible
in the future.
*/
export default class ModelWithMetadata extends Model {
static attributes = Object.assign({}, Model.attributes, {
pluginMetadata: Attributes.Collection({
queryable: true,
itemClass: PluginMetadata,
modelKey: 'pluginMetadata',
jsonKey: 'metadata',
}),
});
static naturalSortOrder() {
return null
}
constructor(...args) {
super(...args)
this.pluginMetadata = this.pluginMetadata || [];
}
// Public accessors
metadataForPluginId(pluginId) {
const metadata = this.metadataObjectForPluginId(pluginId);
if (!metadata) {
return null;
}
return metadata.value;
}
// Private helpers
metadataObjectForPluginId(pluginId) {
return this.pluginMetadata.find(metadata => metadata.pluginId === pluginId);
}
applyPluginMetadata(pluginId, metadataValue) {
let metadata = this.metadataObjectForPluginId(pluginId);
if (!metadata) {
metadata = new PluginMetadata({pluginId});
this.pluginMetadata.push(metadata);
}
metadata.value = metadataValue;
return this;
}
setPluginMetadata(pluginMetadata) {
this.pluginMetadata = pluginMetadata.map(({pluginId, value})=> {
return new PluginMetadata({pluginId, value});
})
return this;
}
}

View file

@ -76,7 +76,7 @@ class Model
@clientId ?= Utils.generateTempId()
@
isSaved: ->
isSavedRemotely: ->
@serverId?
clone: ->

View file

@ -1,10 +1,10 @@
_ = require 'underscore'
Category = require './category'
Model = require './model'
Contact = require './contact'
Actions = require '../actions'
Attributes = require '../attributes'
ModelWithMetadata = require './model-with-metadata'
Function::getter = (prop, get) ->
Object.defineProperty @prototype, prop, {get, configurable: yes}
@ -38,9 +38,9 @@ This class also inherits attributes from {Model}
Section: Models
###
class Thread extends Model
class Thread extends ModelWithMetadata
@attributes: _.extend {}, Model.attributes,
@attributes: _.extend {}, ModelWithMetadata.attributes,
'snippet': Attributes.String
modelKey: 'snippet'

View file

@ -330,7 +330,6 @@ class NylasAPI
"message": require('./models/message')
"contact": require('./models/contact')
"calendar": require('./models/calendar')
"metadata": require('./models/metadata')
getThreads: (accountId, params = {}, requestOptions = {}) ->
requestSuccess = requestOptions.success

View file

@ -463,6 +463,9 @@ class DatabaseStore extends NylasStore
# + Other connections can read from the database, but they will not see
# pending changes.
#
# @param fn {function} callback that will be executed inside a database transaction
# Returns a {Promise} that resolves when the transaction has successfully
# completed.
inTransaction: (fn) ->
t = new DatabaseTransaction(@)
@_transactionQueue ?= new PromiseQueue(1, Infinity)

View file

@ -13,6 +13,7 @@ class DatabaseTransaction
find: (args...) => @database.find(args...)
findBy: (args...) => @database.findBy(args...)
findAll: (args...) => @database.findAll(args...)
modelify: (args...) => @database.modelify(args...)
count: (args...) => @database.count(args...)
findJSONBlob: (args...) => @database.findJSONBlob(args...)

View file

@ -0,0 +1,41 @@
import _ from 'underscore';
import NylasStore from 'nylas-store';
import Actions from '../actions';
import DatabaseStore from './database-store';
import SyncbackMetadataTask from '../tasks/syncback-metadata-task';
class MetadataStore extends NylasStore {
constructor() {
super();
this.listenTo(Actions.setMetadata, this._setMetadata);
}
_setMetadata(modelOrModels, pluginId, metadataValue) {
const models = (modelOrModels instanceof Array) ? modelOrModels : [modelOrModels];
DatabaseStore.inTransaction((t)=> {
// Get the latest version of the models from the datbaase before applying
// metadata in case other plugins also saved metadata, and we don't want
// to overwrite it
return (
t.modelify(_.pluck(models, 'clientId'))
.then((latestModels)=> {
const updatedModels = latestModels.map(m => m.applyPluginMetadata(pluginId, metadataValue));
return (
t.persistModels(updatedModels)
.then(()=> Promise.resolve(updatedModels))
)
})
)
}).then((updatedModels)=> {
updatedModels.forEach((updated)=> {
if (updated.isSavedRemotely()) {
const task = new SyncbackMetadataTask(updated.clientId, updated.constructor.name, pluginId);
Actions.queueTask(task);
}
})
});
}
}
module.exports = new MetadataStore();

View file

@ -1,76 +0,0 @@
import _ from 'underscore'
import Task from './task'
import NylasAPI from '../nylas-api'
import DatabaseStore from '../stores/database-store'
export default class CreateModelTask extends Task {
constructor({data = {}, modelName, endpoint, requiredFields = [], accountId} = {}) {
super()
this.data = data
this.endpoint = endpoint
this.modelName = modelName
this.accountId = accountId
this.requiredFields = requiredFields || []
}
shouldDequeueOtherTask(other) {
return (other instanceof CreateModelTask &&
this.modelName === other.modelName &&
this.accountId === other.accountId &&
this.endpoint === other.endpoint &&
_.isEqual(this.data, other.data))
}
getModelConstructor() {
return require('nylas-exports')[this.modelName]
}
performLocal() {
this.validateRequiredFields(["accountId", "endpoint"])
for (const field of this.requiredFields) {
if (this.data[field] === null || this.data[field] === undefined) {
throw new Error(`Must pass data field "${field}"`)
}
}
const Klass = require('nylas-exports')[this.modelName]
if (!_.isFunction(Klass)) {
throw new Error(`Couldn't find the class for ${this.modelName}`)
}
this.model = new Klass(this.data)
return DatabaseStore.inTransaction((t) => {
return t.persistModel(this.model)
});
}
performRemote() {
return NylasAPI.makeRequest({
path: this.endpoint,
method: "POST",
accountId: this.accountId,
body: this.model.toJSON(),
returnsModel: true,
}).then(() => {
return Promise.resolve(Task.Status.Success)
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const DestroyModelTask = require('./destroy-model-task')
const undoTask = new DestroyModelTask({
clientId: this.model.clientId,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -47,7 +47,7 @@ export default class DestroyModelTask extends Task {
performRemote() {
if (!this.serverId) {
throw new Error("Need a serverId to destroy remotely")
return Promise.resolve(Task.Status.Continue)
}
return NylasAPI.makeRequest({
path: `${this.endpoint}/${this.serverId}`,
@ -58,19 +58,6 @@ export default class DestroyModelTask extends Task {
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
canBeUndone() { return false }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const CreateModelTask = require('./create-model-task')
const undoTask = new CreateModelTask({
data: this.oldModel,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -11,6 +11,7 @@ TaskQueue = require '../stores/task-queue'
SoundRegistry = require '../../sound-registry'
DatabaseStore = require '../stores/database-store'
AccountStore = require '../stores/account-store'
SyncbackMetadataTask = require './syncback-metadata-task'
class MultiRequestProgressMonitor
@ -63,12 +64,6 @@ class SendDraftTask extends Task
unless account
return Promise.reject(new Error("SendDraftTask - you can only send drafts from a configured account."))
if @draft.serverId
@deleteAfterSending =
accountId: @draft.accountId
serverId: @draft.serverId
version: @draft.version
if account.id isnt @draft.accountId
@draft.accountId = account.id
delete @draft.serverId
@ -134,6 +129,8 @@ class SendDraftTask extends Task
# because it's possible for the app to quit without saving state and
# need to re-upload the file.
# This function returns a promise that resolves to the draft when the draft has
# been sent successfully.
_sendAndCreateMessage: =>
NylasAPI.makeRequest
path: "/send"
@ -159,21 +156,26 @@ class SendDraftTask extends Task
return Promise.reject(err)
.then (newMessageJSON) =>
message = new Message().fromJSON(newMessageJSON)
message.clientId = @draft.clientId
message.draft = false
DatabaseStore.inTransaction (t) =>
t.persistModel(message)
@message = new Message().fromJSON(newMessageJSON)
@message.clientId = @draft.clientId
@message.draft = false
# Create new metadata objs on the message based on the existing ones in the draft
@message.setPluginMetadata(@draft.pluginMetadata)
return DatabaseStore.inTransaction (t) =>
DatabaseStore.findBy(Message, {clientId: @draft.clientId})
.then (draft) =>
t.persistModel(@message).then =>
Promise.resolve(draft)
# We DON'T need to delete the local draft because we turn it into a message
# by writing the new message into the database with the same clientId.
#
# We DO, need to make sure that the remote draft has been cleaned up.
#
_deleteRemoteDraft: =>
return Promise.resolve() unless @deleteAfterSending
{accountId, version, serverId} = @deleteAfterSending
_deleteRemoteDraft: ({accountId, version, serverId}) =>
return Promise.resolve() unless serverId
NylasAPI.makeRequest
path: "/drafts/#{serverId}"
accountId: accountId
@ -186,13 +188,19 @@ class SendDraftTask extends Task
Promise.resolve()
_onSuccess: =>
Actions.sendDraftSuccess
draftClientId: @draft.clientId
# Delete attachments from the uploads folder
for upload in @uploaded
Actions.attachmentUploaded(upload)
# Queue a task to save metadata on the message
@message.pluginMetadata.forEach((m)=>
task = new SyncbackMetadataTask(@message.clientId, @message.constructor.name, m.pluginId)
Actions.queueTask(task)
)
Actions.sendDraftSuccess draftClientId: @message.clientId
# Play the sending sound
if NylasEnv.config.get("core.sending.sounds")
SoundRegistry.playSound('send')

View file

@ -7,6 +7,7 @@ TaskQueueStatusStore = require '../stores/task-queue-status-store'
NylasAPI = require '../nylas-api'
Task = require './task'
SyncbackMetadataTask = require './syncback-metadata-task'
{APIError} = require '../errors'
Message = require '../models/message'
Account = require '../models/account'
@ -73,14 +74,23 @@ class SyncbackDraftTask extends Task
#
# The only fields we want to update from the server are the `id` and `version`.
#
draftIsNew = false
DatabaseStore.inTransaction (t) =>
@getLatestLocalDraft().then (draft) =>
# Draft may have been deleted. Oh well.
return Promise.resolve() unless draft
draft.version = version
if draft.serverId isnt id
draft.serverId = id
draftIsNew = true
draft.version = version
t.persistModel(draft)
.thenReturn(true)
.then (draft) =>
if draftIsNew
for {pluginId, value} in draft.pluginMetadata
task = new SyncbackMetadataTask(@draftClientId, draft.constructor.name, pluginId)
Actions.queueTask(task)
return true
getLatestLocalDraft: =>
DatabaseStore.findBy(Message, clientId: @draftClientId).include(Message.attributes.body)

View file

@ -1,14 +1,37 @@
import Metadata from '../models/metadata'
import SyncbackModelTask from './syncback-model-task'
import DatabaseObjectRegistry from '../../database-object-registry'
export default class SyncbackMetadataTask extends SyncbackModelTask {
getModelConstructor() {
return Metadata
constructor(modelClientId, modelClassName, pluginId) {
super({clientId: modelClientId});
this.modelClassName = modelClassName;
this.pluginId = pluginId;
}
getPathAndMethod = (model) => {
const path = `/metadata/${model.objectId}?client_id=${model.applicationId}`;
const method = model.serverId ? "PUT" : "POST"
return {path, method}
getModelConstructor() {
return DatabaseObjectRegistry.classMap()[this.modelClassName];
}
getRequestData = (model) => {
const metadata = model.metadataObjectForPluginId(this.pluginId);
return {
path: `/metadata/${model.id}?client_id=${this.pluginId}`,
method: 'POST',
body: {
object_id: model.serverId,
object_type: this.modelClassName.toLowerCase(),
version: metadata.version,
value: metadata.value,
},
};
};
applyRemoteChangesToModel = (model, {version}) => {
const metadata = model.metadataObjectForPluginId(this.pluginId);
metadata.version = version;
return model;
};
}

View file

@ -39,41 +39,40 @@ export default class SyncbackModelTask extends Task {
getLatestModel = () => {
return DatabaseStore.findBy(this.getModelConstructor(),
{clientId: this.clientId})
}
};
verifyModel = (model) => {
if (model) {
return Promise.resolve(model)
}
throw new Error(`Can't find a '${this.getModelConstructor().name}' model for clientId: ${this.clientId}'`)
}
};
makeRequest = (model) => {
const data = this.getPathAndMethod(model)
return NylasAPI.makeRequest({
const options = _.extend({
accountId: model.accountId,
path: data.path,
method: data.method,
body: model.toJSON(),
returnsModel: false,
})
}
}, this.getRequestData(model));
getPathAndMethod = (model) => {
if (model.serverId) {
return NylasAPI.makeRequest(options);
};
getRequestData = (model) => {
if (model.isSavedRemotely()) {
return {
path: `${this.endpoint}/${model.serverId}`,
body: model.toJSON(),
method: "PUT",
}
}
return {
path: `${this.endpoint}`,
body: model.toJSON(),
method: "POST",
}
}
};
updateLocalModel = ({version, id}) => {
updateLocalModel = (responseJSON) => {
/*
Important: There could be a significant delay between us initiating
the save and getting JSON back from the server. Our local copy of
@ -86,24 +85,36 @@ export default class SyncbackModelTask extends Task {
return this.getLatestModel().then((model) => {
// Model may have been deleted
if (!model) { return Promise.resolve() }
model.version = version
model.serverId = id
return t.persistModel(model)
const changed = this.applyRemoteChangesToModel(model, responseJSON);
return t.persistModel(changed);
})
}).thenReturn(true)
}
};
applyRemoteChangesToModel = (model, {version, id}) => {
model.version = version;
model.serverId = id;
return model;
};
handleRemoteError = (err) => {
if (err instanceof APIError) {
if (!(_.includes(NylasAPI.PermanentErrorCodes, err.statusCode))) {
if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) {
return Promise.resolve([Task.Status.Failed, err])
}
if (err.statusCode === 409) {
return this.handleRemoteVersionConflict(err);
}
return Promise.resolve(Task.Status.Retry)
}
return Promise.resolve([Task.Status.Failed, err])
}
NylasEnv.reportError(err);
return Promise.resolve([Task.Status.Failed, err])
}
};
handleRemoteVersionConflict = (err) => {
return Promise.resolve([Task.Status.Failed, err])
};
canBeUndone() { return false }

View file

@ -1,81 +0,0 @@
import _ from 'underscore'
import Task from './task'
import NylasAPI from '../nylas-api'
import DatabaseStore from '../stores/database-store'
export default class UpdateModelTask extends Task {
constructor({clientId, newData = {}, modelName, endpoint, accountId} = {}) {
super()
this.clientId = clientId
this.newData = newData
this.endpoint = endpoint
this.modelName = modelName
this.accountId = accountId
}
shouldDequeueOtherTask(other) {
return (other instanceof UpdateModelTask &&
this.clientId === other.clientId &&
this.modelName === other.modelName &&
this.accountId === other.accountId &&
this.endpoint === other.endpoint &&
_.isEqual(this.newData, other.newData))
}
getModelConstructor() {
return require('nylas-exports')[this.modelName]
}
performLocal() {
this.validateRequiredFields(["clientId", "accountId", "endpoint"])
const klass = this.getModelConstructor()
if (!_.isFunction(klass)) {
throw new Error(`Couldn't find the class for ${this.modelName}`)
}
return DatabaseStore.findBy(klass, {clientId: this.clientId}).then((model) => {
if (!model) {
throw new Error(`Couldn't find the model with clientId ${this.clientId}`)
}
this.serverId = model.serverId
this.oldModel = model.clone()
const updatedModel = _.extend(model, this.newData)
return DatabaseStore.inTransaction((t) => {
return t.persistModel(updatedModel)
});
});
}
performRemote() {
if (!this.serverId) {
throw new Error("Need a serverId to update remotely")
}
return NylasAPI.makeRequest({
path: `${this.endpoint}/${this.serverId}`,
method: "PUT",
accountId: this.accountId,
body: this.newData,
returnsModel: true,
}).then(() => {
return Promise.resolve(Task.Status.Success)
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const undoTask = new UpdateModelTask({
clientId: this.clientId,
newData: this.oldModel,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -73,7 +73,6 @@ class NylasExports
@require "Contact", 'flux/models/contact'
@require "Category", 'flux/models/category'
@require "Calendar", 'flux/models/calendar'
@require "Metadata", 'flux/models/metadata'
@require "JSONBlob", 'flux/models/json-blob'
@require "DatabaseObjectRegistry", "database-object-registry"
@require "MailboxPerspective", 'mailbox-perspective'
@ -104,8 +103,6 @@ class NylasExports
@require "ChangeUnreadTask", 'flux/tasks/change-unread-task'
@require "SyncbackDraftTask", 'flux/tasks/syncback-draft'
@require "ChangeStarredTask", 'flux/tasks/change-starred-task'
@require "CreateModelTask", 'flux/tasks/create-model-task'
@require "UpdateModelTask", 'flux/tasks/update-model-task'
@require "DestroyModelTask", 'flux/tasks/destroy-model-task'
@require "SyncbackModelTask", 'flux/tasks/syncback-model-task'
@require "SyncbackMetadataTask", 'flux/tasks/syncback-metadata-task'
@ -119,6 +116,7 @@ class NylasExports
@require "OutboxStore", 'flux/stores/outbox-store'
@require "AccountStore", 'flux/stores/account-store'
@require "MessageStore", 'flux/stores/message-store'
@require "MetadataStore", 'flux/stores/metadata-store'
@require "ContactStore", 'flux/stores/contact-store'
@require "CategoryStore", 'flux/stores/category-store'
@require "WorkspaceStore", 'flux/stores/workspace-store'

View file

@ -80,9 +80,6 @@ class Package
@reset()
@declaresNewDatabaseObjects = false
CloudStorage = require './cloud-storage'
@cloudStorage = new CloudStorage(@pluginId())
# TODO FIXME: Use a unique pluginID instead of just the "name"
# This needs to be included here to prevent a circular dependency error
pluginId: -> return @name
@ -187,7 +184,7 @@ class Package
@activateStylesheets()
if @requireMainModule()
localState = NylasEnv.packages.getPackageState(@name) ? {}
@mainModule.activate(localState, @cloudStorage)
@mainModule.activate(localState)
@mainActivated = true
@activateServices()
catch e