diff --git a/spec-nylas/fixtures/delta-sync/sample-clustered.json b/spec-nylas/fixtures/delta-sync/sample-clustered.json
new file mode 100644
index 000000000..40b3fc82c
--- /dev/null
+++ b/spec-nylas/fixtures/delta-sync/sample-clustered.json
@@ -0,0 +1,447 @@
+{
+ "create": {
+ "message": {
+ "9p571g0ie63rg0ekec699ol5e": {
+ "body": "<
>",
+ "files": [],
+ "from": [
+ {
+ "name": "Karen Rustad Tölva",
+ "email": "karen.rustad@gmail.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "62ehtebypazlgokcjvo8o9yak",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "ben@nylas.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ },
+ "date": 1440610192,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "9p571g0ie63rg0ekec699ol5e",
+ "subject": "This is an email following up on the Electron meetup."
+ },
+ "4rv7upa3gzuf1hal48b15lxrx": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "evan (Evan Morikawa)",
+ "email": "noreply+phabricator@nilas.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "cungn0trv89l19mf08a2blyuh",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "ben@inboxapp.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ },
+ "date": 1440609916,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "4rv7upa3gzuf1hal48b15lxrx",
+ "subject": "[Differential] [Accepted] D1936: feat(work): Create the \"Work\" window, move TaskQueue, Nylas sync workers"
+ },
+ "tt4i92q8rtpgrbxaq6viqlf4": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "8qesqxoftrd3nqiig3cz6gu49",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ },
+ "date": 1440609839,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "tt4i92q8rtpgrbxaq6viqlf4",
+ "subject": "Re: Incoming webhook POST body empty"
+ },
+ "7q4wtqzj3nxb42y6ysbl5l73t": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "auro0q0gn0eawfrmqgzbi4f53",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Deleted Items",
+ "id": "b5t18ldx25xibwq5ctuh10u8h",
+ "name": "trash"
+ },
+ "date": 1440609839,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "7q4wtqzj3nxb42y6ysbl5l73t",
+ "subject": "Re: Incoming webhook POST body empty"
+ },
+ "4oesh7fhsbd45t7dx30p03vz1": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "auro0q0gn0eawfrmqgzbi4f53",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Deleted Items",
+ "id": "b5t18ldx25xibwq5ctuh10u8h",
+ "name": "trash"
+ },
+ "date": 1440594160,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "4oesh7fhsbd45t7dx30p03vz1",
+ "subject": "Incoming webhook POST body empty"
+ }
+ },
+ "thread": {
+ "62ehtebypazlgokcjvo8o9yak": {
+ "folders": [
+ {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Inbox",
+ "id": "inbox"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440610192,
+ "has_attachments": false,
+ "first_message_timestamp": 1440610192,
+ "id": "62ehtebypazlgokcjvo8o9yak",
+ "subject": "This is an email following up on the Electron meetup.",
+ "last_message_received_timestamp": 1440610192,
+ "message_ids": [
+ "9p571g0ie63rg0ekec699ol5e"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "ben@nylas.com"
+ },
+ {
+ "name": "Karen Rustad Tölva",
+ "email": "karen.rustad@gmail.com"
+ }
+ ],
+ "version": 0,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ },
+ "auro0q0gn0eawfrmqgzbi4f53": {
+ "folders": [
+ {
+ "display_name": "Deleted Items",
+ "id": "b5t18ldx25xibwq5ctuh10u8h",
+ "name": "trash"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Deleted Items",
+ "id": "trash"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440609839,
+ "has_attachments": false,
+ "first_message_timestamp": 1440594160,
+ "id": "auro0q0gn0eawfrmqgzbi4f53",
+ "subject": "Incoming webhook POST body empty",
+ "last_message_received_timestamp": 1440609839,
+ "message_ids": [
+ "4oesh7fhsbd45t7dx30p03vz1",
+ "7q4wtqzj3nxb42y6ysbl5l73t"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ },
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "version": 1,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ }
+ },
+ "contact": {
+ "1faoqpnn2rhf6mstmyc0ve71u": {
+ "email": "karen.rustad@gmail.com",
+ "object": "contact",
+ "id": "1faoqpnn2rhf6mstmyc0ve71u",
+ "name": "Karen Rustad Tölva",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1"
+ }
+ }
+ },
+ "modify": {
+ "thread": {
+ "cungn0trv89l19mf08a2blyuh": {
+ "folders": [
+ {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Inbox",
+ "id": "inbox"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440609916,
+ "has_attachments": false,
+ "first_message_timestamp": 1440543552,
+ "id": "cungn0trv89l19mf08a2blyuh",
+ "subject": "[Differential] [Request, 1,621 lines] D1936: feat(work): Create the \"Work\" window, move TaskQueue, Nylas sync workers",
+ "last_message_received_timestamp": 1440609916,
+ "message_ids": [
+ "9uznoal93shahahlkesxkmfkr",
+ "4rv7upa3gzuf1hal48b15lxrx"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "ben@inboxapp.com"
+ },
+ {
+ "name": "evan (Evan Morikawa)",
+ "email": "noreply+phabricator@nilas.com"
+ },
+ {
+ "name": "bengotow (Ben Gotow)",
+ "email": "noreply+phabricator@nilas.com"
+ }
+ ],
+ "version": 1,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ },
+ "8qesqxoftrd3nqiig3cz6gu49": {
+ "folders": [
+ {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Inbox",
+ "id": "inbox"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440609839,
+ "has_attachments": false,
+ "first_message_timestamp": 1440594160,
+ "id": "8qesqxoftrd3nqiig3cz6gu49",
+ "subject": "Incoming webhook POST body empty",
+ "last_message_received_timestamp": 1440609839,
+ "message_ids": [
+ "b2dq8u1kbambwor2vy5nz619n",
+ "tt4i92q8rtpgrbxaq6viqlf4"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ },
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "version": 1,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ },
+ "auro0q0gn0eawfrmqgzbi4f53": {
+ "folders": [
+ {
+ "display_name": "Deleted Items",
+ "id": "b5t18ldx25xibwq5ctuh10u8h",
+ "name": "trash"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Deleted Items",
+ "id": "trash"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440609839,
+ "has_attachments": false,
+ "first_message_timestamp": 1440594160,
+ "id": "auro0q0gn0eawfrmqgzbi4f53",
+ "subject": "Incoming webhook POST body empty",
+ "last_message_received_timestamp": 1440609839,
+ "message_ids": [
+ "4oesh7fhsbd45t7dx30p03vz1",
+ "7q4wtqzj3nxb42y6ysbl5l73t"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ },
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "version": 1,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ }
+ }
+ },
+ "destroy": [
+ {
+ "cursor": "bb95ddzqtr2gpmvgrng73t6ih",
+ "object": "thread",
+ "event": "delete",
+ "id": "8qesqxoftrd3nqiig3cz6gu49",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ },
+ {
+ "cursor": "f1pw0buzv336n5xbuod7579cv",
+ "object": "message",
+ "event": "delete",
+ "id": "tt4i92q8rtpgrbxaq6viqlf4",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ },
+ {
+ "cursor": "du99szyqlrujornwr5fgrzxeg",
+ "object": "message",
+ "event": "delete",
+ "id": "b2dq8u1kbambwor2vy5nz619n",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ }
+ ]
+}
diff --git a/spec-nylas/fixtures/delta-sync/sample.json b/spec-nylas/fixtures/delta-sync/sample.json
new file mode 100644
index 000000000..088e2cf97
--- /dev/null
+++ b/spec-nylas/fixtures/delta-sync/sample.json
@@ -0,0 +1,510 @@
+[
+ {
+ "cursor": "9w3el67207f8vvbkv89os5ay6",
+ "attributes": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "Karen Rustad Tölva",
+ "email": "karen.rustad@gmail.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "62ehtebypazlgokcjvo8o9yak",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "ben@nylas.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ },
+ "date": 1440610192,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "9p571g0ie63rg0ekec699ol5e",
+ "subject": "This is an email following up on the Electron meetup."
+ },
+ "object": "message",
+ "event": "create",
+ "id": "9p571g0ie63rg0ekec699ol5e",
+ "timestamp": "2015-08-26T17:35:16.506Z"
+ },
+ {
+ "cursor": "bqw8x58pghty4fw828tezb0va",
+ "attributes": {
+ "folders": [
+ {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Inbox",
+ "id": "inbox"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440610192,
+ "has_attachments": false,
+ "first_message_timestamp": 1440610192,
+ "id": "62ehtebypazlgokcjvo8o9yak",
+ "subject": "This is an email following up on the Electron meetup.",
+ "last_message_received_timestamp": 1440610192,
+ "message_ids": [
+ "9p571g0ie63rg0ekec699ol5e"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "ben@nylas.com"
+ },
+ {
+ "name": "Karen Rustad Tölva",
+ "email": "karen.rustad@gmail.com"
+ }
+ ],
+ "version": 0,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ },
+ "object": "thread",
+ "event": "create",
+ "id": "62ehtebypazlgokcjvo8o9yak",
+ "timestamp": "2015-08-26T17:35:16.506Z"
+ },
+ {
+ "cursor": "ewa1rr5nczdvrwpu11d3454ee",
+ "attributes": {
+ "email": "karen.rustad@gmail.com",
+ "object": "contact",
+ "id": "1faoqpnn2rhf6mstmyc0ve71u",
+ "name": "Karen Rustad Tölva",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1"
+ },
+ "object": "contact",
+ "event": "create",
+ "id": "1faoqpnn2rhf6mstmyc0ve71u",
+ "timestamp": "2015-08-26T17:35:16.506Z"
+ },
+ {
+ "cursor": "756iwtaiufb6vxbtp4lt57cx0",
+ "attributes": {
+ "folders": [
+ {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Inbox",
+ "id": "inbox"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440609916,
+ "has_attachments": false,
+ "first_message_timestamp": 1440543552,
+ "id": "cungn0trv89l19mf08a2blyuh",
+ "subject": "[Differential] [Request, 1,621 lines] D1936: feat(work): Create the \"Work\" window, move TaskQueue, Nylas sync workers",
+ "last_message_received_timestamp": 1440609916,
+ "message_ids": [
+ "9uznoal93shahahlkesxkmfkr",
+ "4rv7upa3gzuf1hal48b15lxrx"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "ben@inboxapp.com"
+ },
+ {
+ "name": "evan (Evan Morikawa)",
+ "email": "noreply+phabricator@nilas.com"
+ },
+ {
+ "name": "bengotow (Ben Gotow)",
+ "email": "noreply+phabricator@nilas.com"
+ }
+ ],
+ "version": 1,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ },
+ "object": "thread",
+ "event": "modify",
+ "id": "cungn0trv89l19mf08a2blyuh",
+ "timestamp": "2015-08-26T17:35:16.506Z"
+ },
+ {
+ "cursor": "ev9ewky31ps7deaf0dm90f4qa",
+ "attributes": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "evan (Evan Morikawa)",
+ "email": "noreply+phabricator@nilas.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "cungn0trv89l19mf08a2blyuh",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "ben@inboxapp.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ },
+ "date": 1440609916,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "4rv7upa3gzuf1hal48b15lxrx",
+ "subject": "[Differential] [Accepted] D1936: feat(work): Create the \"Work\" window, move TaskQueue, Nylas sync workers"
+ },
+ "object": "message",
+ "event": "create",
+ "id": "4rv7upa3gzuf1hal48b15lxrx",
+ "timestamp": "2015-08-26T17:35:16.506Z"
+ },
+ {
+ "cursor": "3gynk12o31m2199ppja76nkve",
+ "attributes": {
+ "folders": [
+ {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Inbox",
+ "id": "inbox"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440609839,
+ "has_attachments": false,
+ "first_message_timestamp": 1440594160,
+ "id": "8qesqxoftrd3nqiig3cz6gu49",
+ "subject": "Incoming webhook POST body empty",
+ "last_message_received_timestamp": 1440609839,
+ "message_ids": [
+ "b2dq8u1kbambwor2vy5nz619n",
+ "tt4i92q8rtpgrbxaq6viqlf4"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ },
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "version": 1,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ },
+ "object": "thread",
+ "event": "modify",
+ "id": "8qesqxoftrd3nqiig3cz6gu49",
+ "timestamp": "2015-08-26T17:35:16.506Z"
+ },
+ {
+ "cursor": "e8wnx217l8v1qx205d4hxvlk1",
+ "attributes": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "8qesqxoftrd3nqiig3cz6gu49",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Inbox",
+ "id": "bn7z083ho0mqfhqd68tio5a70",
+ "name": "inbox"
+ },
+ "date": 1440609839,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "tt4i92q8rtpgrbxaq6viqlf4",
+ "subject": "Re: Incoming webhook POST body empty"
+ },
+ "object": "message",
+ "event": "create",
+ "id": "tt4i92q8rtpgrbxaq6viqlf4",
+ "timestamp": "2015-08-26T17:35:16.506Z"
+ },
+ {
+ "cursor": "asyt17hyf9shing0tbdc1s5n9",
+ "attributes": {
+ "folders": [
+ {
+ "display_name": "Deleted Items",
+ "id": "b5t18ldx25xibwq5ctuh10u8h",
+ "name": "trash"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Deleted Items",
+ "id": "trash"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440609839,
+ "has_attachments": false,
+ "first_message_timestamp": 1440594160,
+ "id": "auro0q0gn0eawfrmqgzbi4f53",
+ "subject": "Incoming webhook POST body empty",
+ "last_message_received_timestamp": 1440609839,
+ "message_ids": [
+ "4oesh7fhsbd45t7dx30p03vz1",
+ "7q4wtqzj3nxb42y6ysbl5l73t"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ },
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "version": 1,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ },
+ "object": "thread",
+ "event": "modify",
+ "id": "auro0q0gn0eawfrmqgzbi4f53",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ },
+ {
+ "cursor": "2r2f2indqv6fc9zptw9y49h97",
+ "attributes": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "auro0q0gn0eawfrmqgzbi4f53",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Deleted Items",
+ "id": "b5t18ldx25xibwq5ctuh10u8h",
+ "name": "trash"
+ },
+ "date": 1440609839,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "7q4wtqzj3nxb42y6ysbl5l73t",
+ "subject": "Re: Incoming webhook POST body empty"
+ },
+ "object": "message",
+ "event": "create",
+ "id": "7q4wtqzj3nxb42y6ysbl5l73t",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ },
+ {
+ "cursor": "ahvjhhsorjc0qe0snhwqydvui",
+ "attributes": {
+ "folders": [
+ {
+ "display_name": "Deleted Items",
+ "id": "b5t18ldx25xibwq5ctuh10u8h",
+ "name": "trash"
+ }
+ ],
+ "object": "thread",
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "tags": [
+ {
+ "name": "Deleted Items",
+ "id": "trash"
+ },
+ {
+ "name": "unread",
+ "id": "unread"
+ }
+ ],
+ "last_message_timestamp": 1440609839,
+ "has_attachments": false,
+ "first_message_timestamp": 1440594160,
+ "id": "auro0q0gn0eawfrmqgzbi4f53",
+ "subject": "Incoming webhook POST body empty",
+ "last_message_received_timestamp": 1440609839,
+ "message_ids": [
+ "4oesh7fhsbd45t7dx30p03vz1",
+ "7q4wtqzj3nxb42y6ysbl5l73t"
+ ],
+ "snippet": "<>",
+ "participants": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ },
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "version": 1,
+ "starred": false,
+ "unread": true,
+ "draft_ids": []
+ },
+ "object": "thread",
+ "event": "create",
+ "id": "auro0q0gn0eawfrmqgzbi4f53",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ },
+ {
+ "cursor": "2e35c6lcfvsx165kuu5534zng",
+ "attributes": {
+ "body": "<>",
+ "files": [],
+ "from": [
+ {
+ "name": "Ismail Pelaseyed",
+ "email": "ismail@sendcase.com"
+ }
+ ],
+ "account_id": "9jx3zd30wqx04p26vh09ptox1",
+ "thread_id": "auro0q0gn0eawfrmqgzbi4f53",
+ "cc": [],
+ "object": "message",
+ "bcc": [],
+ "snippet": "<>",
+ "to": [
+ {
+ "name": "",
+ "email": "support@nylas.com"
+ }
+ ],
+ "folder": {
+ "display_name": "Deleted Items",
+ "id": "b5t18ldx25xibwq5ctuh10u8h",
+ "name": "trash"
+ },
+ "date": 1440594160,
+ "reply_to": [],
+ "events": [],
+ "starred": false,
+ "unread": true,
+ "id": "4oesh7fhsbd45t7dx30p03vz1",
+ "subject": "Incoming webhook POST body empty"
+ },
+ "object": "message",
+ "event": "create",
+ "id": "4oesh7fhsbd45t7dx30p03vz1",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ },
+ {
+ "cursor": "bb95ddzqtr2gpmvgrng73t6ih",
+ "object": "thread",
+ "event": "delete",
+ "id": "8qesqxoftrd3nqiig3cz6gu49",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ },
+ {
+ "cursor": "f1pw0buzv336n5xbuod7579cv",
+ "object": "message",
+ "event": "delete",
+ "id": "tt4i92q8rtpgrbxaq6viqlf4",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ },
+ {
+ "cursor": "du99szyqlrujornwr5fgrzxeg",
+ "object": "message",
+ "event": "delete",
+ "id": "b2dq8u1kbambwor2vy5nz619n",
+ "timestamp": "2015-08-26T17:36:45.297Z"
+ }
+]
diff --git a/spec-nylas/nylas-api-spec.coffee b/spec-nylas/nylas-api-spec.coffee
new file mode 100644
index 000000000..e141bd5a8
--- /dev/null
+++ b/spec-nylas/nylas-api-spec.coffee
@@ -0,0 +1,182 @@
+_ = require 'underscore'
+fs = require 'fs'
+Actions = require '../src/flux/actions'
+NylasAPI = require '../src/flux/nylas-api'
+Thread = require '../src/flux/models/thread'
+DatabaseStore = require '../src/flux/stores/database-store'
+
+describe "NylasAPI", ->
+ describe "handleModel404", ->
+ it "should unpersist the model from the cache that was requested", ->
+ model = new Thread(id: 'threadidhere')
+ spyOn(DatabaseStore, 'unpersistModel')
+ spyOn(DatabaseStore, 'find').andCallFake (klass, id) =>
+ return Promise.resolve(model)
+ NylasAPI._handleModel404("/threads/#{model.id}")
+ advanceClock()
+ expect(DatabaseStore.find).toHaveBeenCalledWith(Thread, model.id)
+ expect(DatabaseStore.unpersistModel).toHaveBeenCalledWith(model)
+
+ it "should not do anything if the model is not in the cache", ->
+ spyOn(DatabaseStore, 'unpersistModel')
+ spyOn(DatabaseStore, 'find').andCallFake (klass, id) =>
+ return Promise.resolve(null)
+ NylasAPI._handleModel404("/threads/1234")
+ advanceClock()
+ expect(DatabaseStore.find).toHaveBeenCalledWith(Thread, '1234')
+ expect(DatabaseStore.unpersistModel).not.toHaveBeenCalledWith()
+
+ it "should not do anything bad if it doesn't recognize the class", ->
+ spyOn(DatabaseStore, 'find')
+ spyOn(DatabaseStore, 'unpersistModel')
+ waitsForPromise ->
+ NylasAPI._handleModel404("/asdasdasd/1234")
+ runs ->
+ expect(DatabaseStore.find).not.toHaveBeenCalled()
+ expect(DatabaseStore.unpersistModel).not.toHaveBeenCalled()
+
+ it "should not do anything bad if the endpoint only has a single segment", ->
+ spyOn(DatabaseStore, 'find')
+ spyOn(DatabaseStore, 'unpersistModel')
+ waitsForPromise ->
+ NylasAPI._handleModel404("/account")
+ runs ->
+ expect(DatabaseStore.find).not.toHaveBeenCalled()
+ expect(DatabaseStore.unpersistModel).not.toHaveBeenCalled()
+
+ describe "handle401", ->
+ it "should post a notification", ->
+ spyOn(Actions, 'postNotification')
+ NylasAPI._handle401('/threads/1234')
+ expect(Actions.postNotification).toHaveBeenCalled()
+ expect(Actions.postNotification.mostRecentCall.args[0].message).toEqual("Nylas can no longer authenticate with your mail provider. You will not be able to send or receive mail. Please log out and sign in again.")
+
+ describe "handleDeltas", ->
+ beforeEach ->
+ @sampleDeltas = JSON.parse(fs.readFileSync('./spec-nylas/fixtures/delta-sync/sample.json'))
+ @sampleClustered = JSON.parse(fs.readFileSync('./spec-nylas/fixtures/delta-sync/sample-clustered.json'))
+
+ it "should immediately fire the received raw deltas event", ->
+ spyOn(Actions, 'longPollReceivedRawDeltas')
+ spyOn(NylasAPI, '_clusterDeltas').andReturn({create: {}, modify: {}, destroy: []})
+ NylasAPI._handleDeltas(@sampleDeltas)
+ expect(Actions.longPollReceivedRawDeltas).toHaveBeenCalled()
+
+ it "should call helper methods for all creates first, then modifications, then destroys", ->
+ spyOn(Actions, 'longPollProcessedDeltas')
+
+ handleDeltaDeletionPromises = []
+ resolveDeltaDeletionPromises = ->
+ fn() for fn in handleDeltaDeletionPromises
+ handleDeltaDeletionPromises = []
+
+ spyOn(NylasAPI, '_handleDeltaDeletion').andCallFake ->
+ new Promise (resolve, reject) ->
+ handleDeltaDeletionPromises.push(resolve)
+
+ handleModelResponsePromises = []
+ resolveModelResponsePromises = ->
+ fn() for fn in handleModelResponsePromises
+ handleModelResponsePromises = []
+
+ spyOn(NylasAPI, '_handleModelResponse').andCallFake ->
+ new Promise (resolve, reject) ->
+ handleModelResponsePromises.push(resolve)
+
+ NylasAPI._handleDeltas(@sampleDeltas)
+
+ createTypes = Object.keys(@sampleClustered['create'])
+ expect(NylasAPI._handleModelResponse.calls.length).toEqual(createTypes.length)
+ expect(NylasAPI._handleModelResponse.calls[0].args[0]).toEqual(_.values(@sampleClustered['create'][createTypes[0]]))
+ expect(NylasAPI._handleDeltaDeletion.calls.length).toEqual(0)
+
+ NylasAPI._handleModelResponse.reset()
+ resolveModelResponsePromises()
+ advanceClock()
+
+ modifyTypes = Object.keys(@sampleClustered['modify'])
+ expect(NylasAPI._handleModelResponse.calls.length).toEqual(modifyTypes.length)
+ expect(NylasAPI._handleModelResponse.calls[0].args[0]).toEqual(_.values(@sampleClustered['modify'][modifyTypes[0]]))
+ expect(NylasAPI._handleDeltaDeletion.calls.length).toEqual(0)
+
+ NylasAPI._handleModelResponse.reset()
+ resolveModelResponsePromises()
+ advanceClock()
+
+ destroyCount = @sampleClustered['destroy'].length
+ expect(NylasAPI._handleDeltaDeletion.calls.length).toEqual(destroyCount)
+ expect(NylasAPI._handleDeltaDeletion.calls[0].args[0]).toEqual(@sampleClustered['destroy'][0])
+
+ expect(Actions.longPollProcessedDeltas).not.toHaveBeenCalled()
+
+ resolveDeltaDeletionPromises()
+ advanceClock()
+
+ expect(Actions.longPollProcessedDeltas).toHaveBeenCalled()
+
+ describe "clusterDeltas", ->
+ beforeEach ->
+ @sampleDeltas = JSON.parse(fs.readFileSync('./spec-nylas/fixtures/delta-sync/sample.json'))
+ @expectedClustered = JSON.parse(fs.readFileSync('./spec-nylas/fixtures/delta-sync/sample-clustered.json'))
+
+ it "should collect create/modify events into a hash by model type", ->
+ {create, modify} = NylasAPI._clusterDeltas(@sampleDeltas)
+ expect(create).toEqual(@expectedClustered.create)
+ expect(modify).toEqual(@expectedClustered.modify)
+
+ it "should collect destroys into an array", ->
+ {destroy} = NylasAPI._clusterDeltas(@sampleDeltas)
+ expect(destroy).toEqual(@expectedClustered.destroy)
+
+ describe "handleDeltaDeletion", ->
+ beforeEach ->
+ @thread = new Thread(id: 'idhere')
+ @delta =
+ "cursor": "bb95ddzqtr2gpmvgrng73t6ih",
+ "object": "thread",
+ "event": "delete",
+ "id": @thread.id,
+ "timestamp": "2015-08-26T17:36:45.297Z"
+
+ it "should resolve if the object cannot be found", ->
+ spyOn(DatabaseStore, 'find').andCallFake (klass, id) =>
+ return Promise.resolve(null)
+ spyOn(DatabaseStore, 'unpersistModel')
+ waitsForPromise =>
+ NylasAPI._handleDeltaDeletion(@delta)
+ runs =>
+ expect(DatabaseStore.find).toHaveBeenCalledWith(Thread, 'idhere')
+ expect(DatabaseStore.unpersistModel).not.toHaveBeenCalled()
+
+ it "should call unpersistModel if the object exists", ->
+ spyOn(DatabaseStore, 'find').andCallFake (klass, id) =>
+ return Promise.resolve(@thread)
+ spyOn(DatabaseStore, 'unpersistModel')
+ waitsForPromise =>
+ NylasAPI._handleDeltaDeletion(@delta)
+ runs =>
+ expect(DatabaseStore.find).toHaveBeenCalledWith(Thread, 'idhere')
+ expect(DatabaseStore.unpersistModel).toHaveBeenCalledWith(@thread)
+
+ # These specs are on hold because this function is changing very soon
+
+ xdescribe "handleModelResponse", ->
+ it "should reject if no JSON is provided", ->
+ it "should resolve if an empty JSON array is provided", ->
+
+ describe "if JSON contains the same object more than once", ->
+ it "should warn", ->
+ it "should omit duplicates", ->
+
+ describe "if JSON contains objects which are of unknown types", ->
+ it "should warn and resolve", ->
+
+ describe "when the object type is `thread`", ->
+ it "should check that models are acceptable", ->
+
+ describe "when the object type is `draft`", ->
+ it "should check that models are acceptable", ->
+
+ it "should call persistModels to save all of the received objects", ->
+
+ it "should resolve with the objects", ->
diff --git a/src/flux/nylas-api.coffee b/src/flux/nylas-api.coffee
index e908fd4f5..7854a82ba 100644
--- a/src/flux/nylas-api.coffee
+++ b/src/flux/nylas-api.coffee
@@ -6,7 +6,6 @@ PriorityUICoordinator = require '../priority-ui-coordinator'
DatabaseStore = require './stores/database-store'
NylasSyncWorker = require './nylas-sync-worker'
NylasLongConnection = require './nylas-long-connection'
-DatabaseObjectRegistry = require '../database-object-registry'
async = require 'async'
PermanentErrorCodes = [400, 404, 500]
@@ -236,12 +235,13 @@ class NylasAPI
{pathname, query} = url.parse(modelUrl, true)
components = pathname.split('/')
- if components.length is 5
- [root, ns, nsId, collection, klassId] = components
- klass = DatabaseObjectRegistry.get(collection[0..-2]) # Warning: threads => thread
+ if components.length is 3
+ [root, collection, klassId] = components
+ klass = @_apiObjectToClassMap[collection[0..-2]] # Warning: threads => thread
if klass and klassId and klassId.length > 0
- console.warn("Deleting #{klass.name}:#{klassId} due to API 404")
+ unless atom.inSpecMode()
+ console.warn("Deleting #{klass.name}:#{klassId} due to API 404")
DatabaseStore.find(klass, klassId).then (model) ->
if model
return DatabaseStore.unpersistModel(model)
@@ -279,6 +279,29 @@ class NylasAPI
if delta.attributes
Object.defineProperty(delta.attributes, '_delta', { get: -> delta })
+ {create, modify, destroy} = @_clusterDeltas(deltas)
+
+ # 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] = @_handleModelResponse(_.values(dict)) for type, dict of create
+ Promise.props(create).then (created) =>
+ # Apply all the deltas to modify objects. Gets promises for handling
+ # each type of model in the `modify` hash, waits for them all to resolve.
+ modify[type] = @_handleModelResponse(_.values(dict)) for type, dict of modify
+ Promise.props(modify).then (modified) =>
+
+ # Now that we've persisted creates/updates, fire an action
+ # that allows other parts of the app to update based on new models
+ # (notifications)
+ if _.flatten(_.values(created)).length > 0
+ Actions.didPassivelyReceiveNewModels(created)
+
+ # Apply all of the deletions
+ destroyPromises = destroy.map(@_handleDeltaDeletion)
+ Promise.settle(destroyPromises).then =>
+ Actions.longPollProcessedDeltas()
+
+ _clusterDeltas: (deltas) ->
# Group deltas by object type so we can mutate the cache efficiently.
# NOTE: This code must not just accumulate creates, modifies and destroys
# but also de-dupe them. We cannot call "persistModels(itemA, itemA, itemB)"
@@ -297,32 +320,14 @@ class NylasAPI
else if delta.event is 'delete'
destroy.push(delta)
- # 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] = @_handleModelResponse(_.values(dict)) for type, dict of create
- Promise.props(create).then (created) =>
- # Apply all the deltas to modify objects. Gets promises for handling
- # each type of model in the `modify` hash, waits for them all to resolve.
- modify[type] = @_handleModelResponse(_.values(dict)) for type, dict of modify
- Promise.props(modify).then (modified) ->
+ {create, modify, destroy}
- # Now that we've persisted creates/updates, fire an action
- # that allows other parts of the app to update based on new models
- # (notifications)
- if _.flatten(_.values(created)).length > 0
- Actions.didPassivelyReceiveNewModels(created)
-
- # Apply all of the deletions
- destroyPromises = destroy.map (delta) ->
- console.log(" - 1 #{delta.object} (#{delta.id})")
- klass = DatabaseObjectRegistry.get(delta.object)
- return unless klass
- DatabaseStore.find(klass, delta.id).then (model) ->
- return Promise.resolve() unless model
- return DatabaseStore.unpersistModel(model)
-
- Promise.settle(destroyPromises).then =>
- Actions.longPollProcessedDeltas()
+ _handleDeltaDeletion: (delta) ->
+ klass = @_apiObjectToClassMap[delta.object]
+ return unless klass
+ DatabaseStore.find(klass, delta.id).then (model) ->
+ return Promise.resolve() unless model
+ return DatabaseStore.unpersistModel(model)
# Returns a Promsie that resolves when any parsed out models (if any)
# have been created and persisted to the database.
@@ -340,38 +345,33 @@ class NylasAPI
console.warn("NylasAPI.handleModelResponse: called with non-unique object set. Maybe an API request returned the same object more than once?")
type = jsons[0].object
- name = @_apiObjectToClassnameMap[type]
- if not name
+ klass = @_apiObjectToClassMap[type]
+ if not klass
console.warn("NylasAPI::handleModelResponse: Received unknown API object type: #{type}")
return Promise.resolve([])
accepted = Promise.resolve(uniquedJSONs)
- if type is "thread"
- Thread = require './models/thread'
- accepted = @_acceptableModelsInResponse(Thread, uniquedJSONs)
- else if type is "draft"
- Message = require './models/message'
- accepted = @_acceptableModelsInResponse(Message, uniquedJSONs)
+ if type is "thread" or type is "draft"
+ accepted = @_acceptableModelsInResponse(klass, uniquedJSONs)
- mapper = (json) ->
- return DatabaseObjectRegistry.deserialize(name, json)
+ mapper = (json) -> (new klass).fromJSON(json)
accepted.map(mapper).then (objects) ->
DatabaseStore.persistModels(objects).then ->
return Promise.resolve(objects)
- _apiObjectToClassnameMap:
- "file": "File"
- "event": "Event"
- "label": "Label"
- "folder": "Folder"
- "thread": "Thread"
- "draft": "Message"
- "account": "Account"
- "message": "Message"
- "contact": "Contact"
- "calendar": "Calendar"
- "metadata": "Metadata"
+ _apiObjectToClassMap:
+ "file": require('./models/file')
+ "event": require('./models/event')
+ "label": require('./models/label')
+ "folder": require('./models/folder')
+ "thread": require('./models/thread')
+ "draft": require('./models/message')
+ "account": require('./models/account')
+ "message": require('./models/message')
+ "contact": require('./models/contact')
+ "calendar": require('./models/calendar')
+ "metadata": require('./models/metadata')
_acceptableModelsInResponse: (klass, jsons) ->
# Filter out models that are locked by pending optimistic changes