fix(sync): Pull down and associate metadata during initial sync

Summary:
Snooze should wait for categories on all accounts

Fix authPlugin to rembmer `plugin+accountId`, not pluginId, add specs

categories() returned [], categories(acctId) returned {}

dry up sync worker, fetch metadata before anything else

Test Plan: Run tests

Reviewers: drew, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D2693
This commit is contained in:
Ben Gotow 2016-03-10 11:06:06 -08:00
parent 041817c589
commit f706d4d80f
7 changed files with 269 additions and 63 deletions

View file

@ -58,8 +58,8 @@ const SnoozeUtils = {
}) })
}, },
whenCategoriesReady() { whenCategoriesReady(accountId) {
const categoriesReady = ()=> CategoryStore.categories().length > 0 const categoriesReady = ()=> CategoryStore.categories(accountId).length > 0;
if (!categoriesReady()) { if (!categoriesReady()) {
return new Promise((resolve)=> { return new Promise((resolve)=> {
const unsubscribe = CategoryStore.listen(()=> { const unsubscribe = CategoryStore.listen(()=> {
@ -74,7 +74,7 @@ const SnoozeUtils = {
}, },
getSnoozeCategory(accountId, categoryName = SNOOZE_CATEGORY_NAME) { getSnoozeCategory(accountId, categoryName = SNOOZE_CATEGORY_NAME) {
return SnoozeUtils.whenCategoriesReady() return SnoozeUtils.whenCategoriesReady(accountId)
.then(()=> { .then(()=> {
const allCategories = CategoryStore.categories(accountId) const allCategories = CategoryStore.categories(accountId)
const category = _.findWhere(allCategories, {displayName: categoryName}) const category = _.findWhere(allCategories, {displayName: categoryName})

View file

@ -106,23 +106,51 @@ class NylasSyncWorker
# we'll backoff and restart the timer. # we'll backoff and restart the timer.
@_resumeTimer.cancel() @_resumeTimer.cancel()
@fetchCollection('threads') needed = [
if @_account.usesLabels() {model: 'threads'},
@fetchCollection('labels', {initialPageSize: 1000}) {model: "#{@_account.organizationUnit}s", initialPageSize: 1000}
if @_account.usesFolders() {model: 'drafts'},
@fetchCollection('folders', {initialPageSize: 1000}) {model: 'contacts'},
@fetchCollection('drafts') {model: 'calendars'},
@fetchCollection('contacts') {model: 'events'},
@fetchCollection('calendars') ].filter ({model}) =>
@fetchCollection('events') @shouldFetchCollection(model)
fetchCollection: (model, options = {}) -> return if needed.length is 0
return unless @_state
@fetchAllMetadata =>
needed.forEach ({model, initialPageSize}) =>
@fetchCollection(model, initialPageSize)
fetchAllMetadata: (finished) ->
@_metadata = {}
makeMetadataRequest = (offset) =>
limit = 200
@_fetchWithErrorHandling
path: "/metadata"
qs: {limit, offset}
success: (data) =>
for metadatum in data
@_metadata[metadatum.object_id] ?= []
@_metadata[metadatum.object_id].push(metadatum)
if data.length is limit
makeMetadataRequest(offset + limit)
else
console.log("Retrieved #{offset + data.length} metadata objects")
finished()
makeMetadataRequest(0)
shouldFetchCollection: (model) ->
return false unless @_state
state = @_state[model] ? {} state = @_state[model] ? {}
return if state.complete and not options.force? return false if state.complete
return if state.busy return false if state.busy
return true
fetchCollection: (model, initialPageSize = INITIAL_PAGE_SIZE) ->
state = @_state[model] ? {}
state.complete = false state.complete = false
state.error = null state.error = null
state.busy = true state.busy = true
@ -138,7 +166,7 @@ class NylasSyncWorker
@fetchCollectionPage(model, {limit, offset}) @fetchCollectionPage(model, {limit, offset})
else else
@fetchCollectionPage(model, { @fetchCollectionPage(model, {
limit: options.initialPageSize ? INITIAL_PAGE_SIZE, limit: initialPageSize,
offset: 0 offset: 0
}) })
@ -146,23 +174,17 @@ class NylasSyncWorker
@writeState() @writeState()
fetchCollectionCount: (model) -> fetchCollectionCount: (model) ->
@_api.makeRequest @_fetchWithErrorHandling
accountId: @_account.id
path: "/#{model}" path: "/#{model}"
returnsModel: false qs: {view: 'count'}
qs:
view: 'count'
success: (response) => success: (response) =>
return if @_terminated
@updateTransferState(model, count: response.count) @updateTransferState(model, count: response.count)
error: (err) =>
return if @_terminated
@_resumeTimer.backoff()
@_resumeTimer.start()
fetchCollectionPage: (model, params = {}) -> fetchCollectionPage: (model, params = {}) ->
requestStartTime = Date.now() requestStartTime = Date.now()
requestOptions = requestOptions =
metadataToAttach: @_metadata
error: (err) => error: (err) =>
return if @_terminated return if @_terminated
@_fetchCollectionPageError(model, params, err) @_fetchCollectionPageError(model, params, err)
@ -203,6 +225,21 @@ class NylasSyncWorker
_hasNoInbox: (json) -> _hasNoInbox: (json) ->
return not _.any(json, (obj) -> obj.name is "inbox") return not _.any(json, (obj) -> obj.name is "inbox")
_fetchWithErrorHandling: ({path, qs, success, error}) ->
@_api.makeRequest
accountId: @_account.id
returnsModel: false
path: path
qs: qs
success: (response) =>
return if @_terminated
success(response) if success
error: (err) =>
return if @_terminated
@_resumeTimer.backoff()
@_resumeTimer.start()
error(err) if error
_fetchCollectionPageError: (model, params, err) -> _fetchCollectionPageError: (model, params, err) ->
@_resumeTimer.backoff() @_resumeTimer.backoff()
@_resumeTimer.start() @_resumeTimer.start()

View file

@ -17,6 +17,7 @@ describe "NylasSyncWorker", ->
@apiRequests.push({account, model:'threads', params, requestOptions}) @apiRequests.push({account, model:'threads', params, requestOptions})
@apiCursorStub = undefined @apiCursorStub = undefined
spyOn(NylasSyncWorker.prototype, 'fetchAllMetadata').andCallFake (cb) -> cb()
spyOn(DatabaseTransaction.prototype, 'persistJSONBlob').andReturn(Promise.resolve()) spyOn(DatabaseTransaction.prototype, 'persistJSONBlob').andReturn(Promise.resolve())
spyOn(DatabaseStore, 'findJSONBlob').andCallFake (key) => spyOn(DatabaseStore, 'findJSONBlob').andCallFake (key) =>
if key is "NylasSyncWorker:#{TEST_ACCOUNT_ID}" if key is "NylasSyncWorker:#{TEST_ACCOUNT_ID}"
@ -37,6 +38,7 @@ describe "NylasSyncWorker", ->
@account = new Account(clientId: TEST_ACCOUNT_CLIENT_ID, serverId: TEST_ACCOUNT_ID, organizationUnit: 'label') @account = new Account(clientId: TEST_ACCOUNT_CLIENT_ID, serverId: TEST_ACCOUNT_ID, organizationUnit: 'label')
@worker = new NylasSyncWorker(@api, @account) @worker = new NylasSyncWorker(@api, @account)
@worker._metadata = {"a": [{"id":"b"}]}
@connection = @worker.connection() @connection = @worker.connection()
spyOn(@connection, 'start') spyOn(@connection, 'start')
advanceClock() advanceClock()
@ -177,35 +179,58 @@ describe "NylasSyncWorker", ->
expect(nextState.threads.count).toEqual(1001) expect(nextState.threads.count).toEqual(1001)
describe "resumeFetches", -> describe "resumeFetches", ->
it "should fetch collections", -> it "should fetch metadata first and fetch other collections when metadata is ready", ->
fetchAllMetadataCallback = null
jasmine.unspy(NylasSyncWorker.prototype, 'fetchAllMetadata')
spyOn(NylasSyncWorker.prototype, 'fetchAllMetadata').andCallFake (cb) =>
fetchAllMetadataCallback = cb
spyOn(@worker, 'fetchCollection') spyOn(@worker, 'fetchCollection')
@worker._state = {}
@worker.resumeFetches() @worker.resumeFetches()
expect(@worker.fetchCollection.calls.map (call) -> call.args[0]).toEqual(['threads', 'labels', 'drafts', 'contacts', 'calendars', 'events']) expect(@worker.fetchAllMetadata).toHaveBeenCalled()
expect(@worker.fetchCollection.calls.length).toBe(0)
fetchAllMetadataCallback()
expect(@worker.fetchCollection.calls.length).not.toBe(0)
it "should fetch collections for which `shouldFetchCollection` returns true", ->
spyOn(@worker, 'fetchCollection')
spyOn(@worker, 'shouldFetchCollection').andCallFake (collection) =>
return collection in ['threads', 'labels', 'drafts']
@worker.resumeFetches()
expect(@worker.fetchCollection.calls.map (call) -> call.args[0]).toEqual(['threads', 'labels', 'drafts'])
it "should be called when Actions.retryInitialSync is received", -> it "should be called when Actions.retryInitialSync is received", ->
spyOn(@worker, 'resumeFetches').andCallThrough() spyOn(@worker, 'resumeFetches').andCallThrough()
Actions.retryInitialSync() Actions.retryInitialSync()
expect(@worker.resumeFetches).toHaveBeenCalled() expect(@worker.resumeFetches).toHaveBeenCalled()
describe "fetchCollection", -> describe "shouldFetchCollection", ->
beforeEach -> it "should return false if the collection sync is already in progress", ->
@apiRequests = []
it "should not start if the collection sync is already in progress", ->
@worker._state.threads = { @worker._state.threads = {
'busy': true 'busy': true
'complete': false 'complete': false
} }
@worker.fetchCollection('threads') expect(@worker.shouldFetchCollection('threads')).toBe(false)
expect(@apiRequests.length).toBe(0)
it "should not start if the collection sync is already complete", -> it "should return false if the collection sync is already complete", ->
@worker._state.threads = { @worker._state.threads = {
'busy': false 'busy': false
'complete': true 'complete': true
} }
@worker.fetchCollection('threads') expect(@worker.shouldFetchCollection('threads')).toBe(false)
expect(@apiRequests.length).toBe(0)
it "should return true otherwise", ->
@worker._state.threads = {
'busy': false
'complete': false
}
expect(@worker.shouldFetchCollection('threads')).toBe(true)
@worker._state.threads = undefined
expect(@worker.shouldFetchCollection('threads')).toBe(true)
describe "fetchCollection", ->
beforeEach ->
@apiRequests = []
it "should start the request for the model count", -> it "should start the request for the model count", ->
@worker._state.threads = { @worker._state.threads = {
@ -216,7 +241,16 @@ describe "NylasSyncWorker", ->
expect(@apiRequests[0].requestOptions.path).toBe('/threads') expect(@apiRequests[0].requestOptions.path).toBe('/threads')
expect(@apiRequests[0].requestOptions.qs.view).toBe('count') expect(@apiRequests[0].requestOptions.qs.view).toBe('count')
describe "when there is no errorRequestRange saved", -> it "should pass any metadata it preloaded", ->
@worker._state.threads = {
'busy': false
'complete': false
}
@worker.fetchCollection('threads')
expect(@apiRequests[1].model).toBe('threads')
expect(@apiRequests[1].requestOptions.metadataToAttach).toBe(@worker._metadata)
describe "when there is not a previous page failure (`errorRequestRange`)", ->
it "should start the first request for models", -> it "should start the first request for models", ->
@worker._state.threads = { @worker._state.threads = {
'busy': false 'busy': false
@ -226,7 +260,7 @@ describe "NylasSyncWorker", ->
expect(@apiRequests[1].model).toBe('threads') expect(@apiRequests[1].model).toBe('threads')
expect(@apiRequests[1].params.offset).toBe(0) expect(@apiRequests[1].params.offset).toBe(0)
describe "when there is an errorRequestRange saved", -> describe "when there is a previous page failure (`errorRequestRange`)", ->
beforeEach -> beforeEach ->
@worker._state.threads = @worker._state.threads =
'count': 1200 'count': 1200

View file

@ -8,6 +8,110 @@ DatabaseStore = require '../src/flux/stores/database-store'
DatabaseTransaction = require '../src/flux/stores/database-transaction' DatabaseTransaction = require '../src/flux/stores/database-transaction'
describe "NylasAPI", -> describe "NylasAPI", ->
describe "authPlugin", ->
beforeEach ->
NylasAPI.pluginsSupported = true
@authGetResponse = null
@authPostResponse = null
@error = null
@resolved = false
spyOn(NylasEnv.config, 'set')
spyOn(NylasEnv.config, 'get').andReturn(null)
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
return @authGetResponse if options.method is 'GET' and @authGetResponse
return @authPostResponse if options.method is 'POST' and @authPostResponse
return new Promise (resolve, reject) -> #never respond
it "should reject if the current environment does not support plugins", ->
NylasAPI.pluginsSupported = false
NylasAPI.authPlugin('PID', 'PSECRET', TEST_ACCOUNT_ID).catch (err) => @error = err
waitsFor =>
@error
runs =>
expect(@error.message).toEqual('Sorry, this feature is only available when N1 is running against the hosted version of the Nylas Sync Engine.')
it "should reject if no account can be found for the given accountOrId", ->
NylasAPI.authPlugin('PID', 'PSECRET', 'randomAccountId').catch (err) => @error = err
waitsFor =>
@error
runs =>
expect(@error.message).toEqual('Invalid account')
it "should resolve if the plugin has been successfully authed with accountOrId already", ->
jasmine.unspy(NylasEnv.config, 'get')
spyOn(NylasEnv.config, 'get').andCallFake (key) =>
return Date.now() if key is "plugins.PID.lastAuth.#{TEST_ACCOUNT_ID}"
return null
NylasAPI.authPlugin('PID', 'PSECRET', TEST_ACCOUNT_ID).then (err) =>
@resolved = true
waitsFor =>
@resolved
expect(NylasAPI.makeRequest).not.toHaveBeenCalled()
describe "check for existing auth", ->
it "should GET /auth/plugin to check if the plugin has been authed", ->
@authGetResponse = Promise.resolve({authed: true})
NylasAPI.authPlugin('PID', 'PSECRET', TEST_ACCOUNT_ID)
advanceClock()
expect(NylasAPI.makeRequest).toHaveBeenCalledWith({
returnsModel: false,
method: 'GET',
accountId: 'test-account-server-id',
path: '/auth/plugin?client_id=PID'
})
it "should record a successful auth in the config and resolve without making a POST", ->
@authGetResponse = Promise.resolve({authed: true})
@authPostResponse = null
NylasAPI.authPlugin('PID', 'PSECRET', TEST_ACCOUNT_ID).then => @resolved = true
waitsFor =>
@resolved
runs =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
expect(NylasEnv.config.set.mostRecentCall.args[0]).toEqual("plugins.PID.lastAuth.#{TEST_ACCOUNT_ID}")
it "should propagate any network errors back to the caller", ->
@authGetResponse = Promise.reject(new Error("Network failure!"))
NylasAPI.authPlugin('PID', 'PSECRET', TEST_ACCOUNT_ID).catch (err) => @error = err
advanceClock()
advanceClock()
expect(@error.message).toBe("Network failure!")
expect(NylasEnv.config.set).not.toHaveBeenCalled()
describe "request for auth", ->
it "should POST to /auth/plugin with the client id and record a successful auth", ->
@authGetResponse = Promise.resolve({authed: false})
@authPostResponse = Promise.resolve({authed: true})
NylasAPI.authPlugin('PID', 'PSECRET', TEST_ACCOUNT_ID).then => @resolved = true
waitsFor =>
@resolved
runs =>
expect(NylasAPI.makeRequest.calls[0].args[0]).toEqual({
returnsModel: false,
method: 'GET',
accountId: 'test-account-server-id',
path: '/auth/plugin?client_id=PID'
})
expect(NylasAPI.makeRequest.calls[1].args[0]).toEqual({
returnsModel: false,
method: 'POST',
accountId: 'test-account-server-id',
path: '/auth/plugin',
body: {client_id: 'PID'},
json: true
})
setCall = NylasEnv.config.set.mostRecentCall
expect(setCall.args[0]).toEqual("plugins.PID.lastAuth.#{TEST_ACCOUNT_ID}")
it "should propagate any network errors back to the caller", ->
@authGetResponse = Promise.resolve({authed: false})
@authPostResponse = Promise.reject(new Error("Network failure!"))
NylasAPI.authPlugin('PID', 'PSECRET', TEST_ACCOUNT_ID).catch (err) => @error = err
waitsFor =>
@error
runs =>
expect(@error.message).toBe("Network failure!")
describe "handleModel404", -> describe "handleModel404", ->
it "should unpersist the model from the cache that was requested", -> it "should unpersist the model from the cache that was requested", ->
model = new Thread(id: 'threadidhere') model = new Thread(id: 'threadidhere')

View file

@ -23,6 +23,13 @@ class PluginMetadata extends Model {
this.version = this.version || 0; this.version = this.version || 0;
} }
fromJSON(json) {
super.fromJSON(json);
// application_id is used in JSON coming down from the API
this.pluginId = this.pluginId || json.application_id;
}
get id() { get id() {
return this.pluginId return this.pluginId
} }

View file

@ -121,7 +121,6 @@ class NylasAPI
SampleTemporaryErrorCode: SampleTemporaryErrorCode SampleTemporaryErrorCode: SampleTemporaryErrorCode
constructor: -> constructor: ->
@_workers = []
@_lockTracker = new NylasAPIChangeLockTracker() @_lockTracker = new NylasAPIChangeLockTracker()
NylasEnv.config.onDidChange('env', @_onConfigChanged) NylasEnv.config.onDidChange('env', @_onConfigChanged)
@ -131,6 +130,7 @@ class NylasAPI
prev = {@AppID, @APIRoot, @APITokens} prev = {@AppID, @APIRoot, @APITokens}
if NylasEnv.inSpecMode() if NylasEnv.inSpecMode()
@pluginsSupported = true
env = "testing" env = "testing"
else else
env = NylasEnv.config.get('env') env = NylasEnv.config.get('env')
@ -143,12 +143,15 @@ class NylasAPI
if env in ['production'] if env in ['production']
@AppID = 'eco3rpsghu81xdc48t5qugwq7' @AppID = 'eco3rpsghu81xdc48t5qugwq7'
@APIRoot = 'https://api.nylas.com' @APIRoot = 'https://api.nylas.com'
@pluginsSupported = true
else if env in ['staging', 'development'] else if env in ['staging', 'development']
@AppID = '54miogmnotxuo5st254trcmb9' @AppID = '54miogmnotxuo5st254trcmb9'
@APIRoot = 'https://api-staging.nylas.com' @APIRoot = 'https://api-staging.nylas.com'
@pluginsSupported = true
else if env in ['experimental'] else if env in ['experimental']
@AppID = 'c5dis00do2vki9ib6hngrjs18' @AppID = 'c5dis00do2vki9ib6hngrjs18'
@APIRoot = 'https://api-staging-experimental.nylas.com' @APIRoot = 'https://api-staging-experimental.nylas.com'
@pluginsSupported = true
else if env in ['local'] else if env in ['local']
@AppID = NylasEnv.config.get('syncEngine.AppID') or 'n/a' @AppID = NylasEnv.config.get('syncEngine.AppID') or 'n/a'
@APIRoot = 'http://localhost:5555' @APIRoot = 'http://localhost:5555'
@ -321,6 +324,12 @@ class NylasAPI
.then -> .then ->
return Promise.resolve(responseModels) return Promise.resolve(responseModels)
_attachMetadataToResponse: (jsons, metadataToAttach) ->
return unless metadataToAttach
for obj in jsons
if metadataToAttach[obj.id]
obj.metadata = metadataToAttach[obj.id]
_apiObjectToClassMap: _apiObjectToClassMap:
"file": require('./models/file') "file": require('./models/file')
"event": require('./models/event') "event": require('./models/event')
@ -341,6 +350,7 @@ class NylasAPI
if result.messages if result.messages
messages = messages.concat(result.messages) messages = messages.concat(result.messages)
if messages.length > 0 if messages.length > 0
@_attachMetadataToResponse(messages, requestOptions.metadataToAttach)
@_handleModelResponse(messages) @_handleModelResponse(messages)
if requestSuccess if requestSuccess
requestSuccess(json) requestSuccess(json)
@ -350,11 +360,17 @@ class NylasAPI
getCollection: (accountId, collection, params={}, requestOptions={}) -> getCollection: (accountId, collection, params={}, requestOptions={}) ->
throw (new Error "getCollection requires accountId") unless accountId throw (new Error "getCollection requires accountId") unless accountId
requestSuccess = requestOptions.success
@makeRequest _.extend requestOptions, @makeRequest _.extend requestOptions,
path: "/#{collection}" path: "/#{collection}"
accountId: accountId accountId: accountId
qs: params qs: params
returnsModel: true returnsModel: false
success: (jsons) =>
@_attachMetadataToResponse(jsons, requestOptions.metadataToAttach)
@_handleModelResponse(jsons)
if requestSuccess
requestSuccess(jsons)
incrementRemoteChangeLock: (klass, id) -> incrementRemoteChangeLock: (klass, id) ->
@_lockTracker.increment(klass, id) @_lockTracker.increment(klass, id)
@ -385,14 +401,19 @@ class NylasAPI
# the plugin server couldn't be reached or failed to respond properly when authing # the plugin server couldn't be reached or failed to respond properly when authing
# the account, or that the Nylas API couldn't be reached. # the account, or that the Nylas API couldn't be reached.
authPlugin: (pluginId, pluginName, accountOrId) -> authPlugin: (pluginId, pluginName, accountOrId) ->
account = if accountOrId instanceof Account unless @pluginsSupported
accountOrId return Promise.reject(new Error('Sorry, this feature is only available when N1 is running against the hosted version of the Nylas Sync Engine.'))
if accountOrId instanceof Account
account = accountOrId
else else
AccountStore ?= require './stores/account-store' AccountStore ?= require './stores/account-store'
AccountStore.accountForId(accountOrId) account = AccountStore.accountForId(accountOrId)
Promise.reject(new Error('Invalid account')) unless account
cacheKey = "plugins.#{pluginId}.lastAuthTimestamp" unless account
return Promise.reject(new Error('Invalid account'))
cacheKey = "plugins.#{pluginId}.lastAuth.#{account.id}"
if NylasEnv.config.get(cacheKey) if NylasEnv.config.get(cacheKey)
return Promise.resolve() return Promise.resolve()
@ -401,22 +422,24 @@ class NylasAPI
method: "GET", method: "GET",
accountId: account.id, accountId: account.id,
path: "/auth/plugin?client_id=#{pluginId}" path: "/auth/plugin?client_id=#{pluginId}"
})
.then (result) => }).then (result) =>
if result.authed if result.authed
NylasEnv.config.set(cacheKey, Date.now()) NylasEnv.config.set(cacheKey, Date.now())
return Promise.resolve() return Promise.resolve()
else
# Enable to show a prompt to the user # Enable to show a prompt to the user
# return @_requestPluginAuth(pluginName, account).then => # return @_requestPluginAuth(pluginName, account).then =>
return @makeRequest({ return @makeRequest({
returnsModel: false, returnsModel: false,
method: "POST", method: "POST",
accountId: account.id, accountId: account.id,
path: "/auth/plugin", path: "/auth/plugin",
body: {client_id: pluginId}, body: {client_id: pluginId},
json: true json: true
}) }).then (result) =>
NylasEnv.config.set(cacheKey, Date.now()) if result.authed
return Promise.resolve()
_requestPluginAuth: (pluginName, account) -> _requestPluginAuth: (pluginName, account) ->
{dialog} = require('electron').remote {dialog} = require('electron').remote

View file

@ -32,7 +32,8 @@ class CategoryStore extends NylasStore
.subscribe(@_onCategoriesChanged) .subscribe(@_onCategoriesChanged)
byId: (accountOrId, categoryId) -> byId: (accountOrId, categoryId) ->
@categories(accountOrId)[categoryId] categories = @_categoryCache[asAccountId(accountOrId)] ? {}
categories[categoryId]
# Public: Returns an array of all categories for an account, both # Public: Returns an array of all categories for an account, both
# standard and user generated. The items returned by this function will be # standard and user generated. The items returned by this function will be
@ -40,7 +41,7 @@ class CategoryStore extends NylasStore
# #
categories: (accountOrId = null) -> categories: (accountOrId = null) ->
if accountOrId if accountOrId
@_categoryCache[asAccountId(accountOrId)] ? {} _.values(@_categoryCache[asAccountId(accountOrId)]) ? []
else else
all = [] all = []
for accountId, categories of @_categoryCache for accountId, categories of @_categoryCache