Mailspring/src/cloud-storage.es6
Evan Morikawa d4db0737cf feat(error): improve error reporting. Now NylasEnv.reportError
Summary:
The goal is to let us see what plugins are throwing errors on Sentry.

We are using a Sentry `tag` to identify and group plugins and their
errors.

Along the way, I cleaned up the error catching and reporting system. There
was a lot of duplicate error logic (that wasn't always right) and some
legacy Atom error handling.

Now, if you catch an error that we should report (like when handling
extensions), call `NylasEnv.reportError`. This used to be called
`emitError` but I changed it to `reportError` to be consistent with the
ErrorReporter and be a bit more indicative of what it does.

In the production version, the `ErrorLogger` will forward the request to
the `nylas-private-error-reporter` which will report to Sentry.

The `reportError` function also now inspects the stack to determine which
plugin(s) it came from. These are passed along to Sentry.

I also cleaned up the `console.log` and `console.error` code. We were
logging errors multiple times making the console confusing to read. Worse
is that we were logging the `error` object, which would print not the
stack of the actual error, but rather the stack of where the console.error
was logged from. Printing `error.stack` instead shows much more accurate
stack traces.

See changes in the Edgehill repo here: 8c4a86eb7e

Test Plan: Manual

Reviewers: juan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2509
2016-02-03 18:06:52 -05:00

203 lines
6.6 KiB
JavaScript

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")
}
}