LevelUP: Promises, Promises…

Promisifying LevelDB in Node to escape callback hell…

Solution

.post '/save',debug_request,who_are_you,needs_permission,(req,res)->
	# Shorthands to enhance readability.
	r=req.body # Request parameters/data.
	a=req.user # Authenticated profile.
	p=null # "Global" var to hold main modified profile while patching it.
	#...
	# Chain everything to catch exceptions — asynchronously.
	changes=[] # Modified profiles to save in batch, transaction like, to protect consistency.
	new promise (resolve,reject)->resolve null # Blank promise just to bootstrap chain.
	.then ->
		# Validations…
		r.name=r.name?.trim() #??? DRY
		a.children?=[]
		if not is_parent and not r.name then res.send 400,'Children must have names.';return promise.reject false # Abort chain.
		if r.password then r.password=bcrypt.hashSync r.password
		# Permissions?
		if (r.username or r.current_username) is a.username and r.role isnt 'parent' then res.send 403,'Not allowed to change own role.';return promise.reject false # Abort chain.
		if r.role is 'parent' and r.current_username isnt a.username then res.send 403,'Not allowed to make children parents.';return promise.reject false # Abort chain.
		#...
		# If new username, check availability: prevent overwriting other profiles.
		if is_new and r.username or is_key_change then return users.get r.username
	.catch (err)-> # Expect notFound — means username available.
		unless err.notFound then throw err # Only catch notFound.
	.then (v)-> # Expected nothing; value means username exists in DB, so throw an error.
		if v then console.log 'Exists',v;res.send 400,'''Can't change to this username — already in use.''';return promise.reject false # Abort chain.
		# Fetch (main) profile to update. Three usecases:
		if r.current_username is a.username then return a # Parent updating theirself; already loaded authenticated profile.
		else if is_baby then return null # Nothing to fetch.
		else return users.get r.current_username # Promisified LDB!
	.then (v)-> # Expect optional value, resolved if it was a promise.
		if v then p=v else p=r
		# Save with new username.
		if is_key_change then p.username=r.username
		#...
		# Modify profile.
		for f in ['name','lastname','password','age','gender','language'] #if r.password then p.password=r.password
			do (f)->
				if r[f]? then p[f]=r[f]
		# If changing username, delete old profile.
		if is_key_change
			changes.push type:'del',key:r.current_username
			# Child? Update parent, too.
			if is_parent # Parent.
				# Need to load all of them!
				return (promise.denodeify mget) users,a.children
			else # Child.
				a.children.splice a.children.indexOf(r.current_username),1,r.username
				changes.push type:'put',key:a.username,value:a
	.then (vs)-> # Wait for all children to load, if promised that.
		# Parent? Update all children.
		if vs then for k of vs
			do ->
				# Fix parent reference and update child.
				vs[k].parent=a.username
				changes.push type:'put',key:k,value:vs[k]
	.then ->
		#...
		changes.push type:'put',key:r.username,value:p 
		# End transaction.
		return users.batch changes # Promisified!
	.then ->
		res.send 200,p # Return updated profile with possibly server-side generated stuff.
	# Error handling for everything (that wasn't already).
	.done null,(err)->
		if err
			console.log '500 because',err
			res.send 500,'Houston, we have a problem.'

Break it down!

  1. This is the key pattern requiring use of promises: the combination of branching and async:
    	if whatever then return users.get r.username
    .catch (err)-> # Expect notFound — means username available.
    	unless err.notFound then throw err
    	#...

    It’s ugly! But, arguably, localized: handling (synchronously) the notFound is kept relatively near the “whatever” that caused it. Technically, they still happen in separate functions — there’s an implicit void return before the .catch (which is only called if some exception was thrown).

  2. “.post ‘/save’…->”: context is an Express route handler; the usual middleware, plus custom authentication and authorization, gave us req.body, req.user, etc.
  3. “users” is a promisified LevelDB:
    db=(require 'level-promise') (require 'level-sublevel') (require 'level') 'data',keyEncoding:'utf8',valueEncoding:'json' 
    users=db.sublevel 'users'
    
  4. There’s also “mget=require ‘level-mget'” which we have to promisify manually:
    return (promise.denodeify mget) users,p.children
    
  5. Chain abortion is a recurring pattern:
    if not is_parent and not r.name
    	res.send 400,'Children must have names.'
    	# Abort chain.
    	return promise.reject false 
    

    Could probably DRY this with some syntactic sugar.

  6. “promise.reject false” is required to distinguish from other, unhandled exceptions.

Problems: promises suck

  1. Anything that needs that much explanation (google it) is broken: overcomplicated, for such a common usecase.
  2. I call BS on the encapsulated value excuse — it’s always been a control flow issue.
  3. Localization is still somewhat violated?
  4. “You cannot really end a chain, unless you throw an exception that bubbles until its end.”

Callback hell?

  1. Syntactically ugly, but not the real problem: it’s the localization violation, dummies. All those trivial examples with linear flow, no branching, can still be done using anonymous callbacks — and pyramids of doom — but non-trivial flows require decoupling logic into arbitrary (named) functions, thus breaking localization, cohesion. Logic is split at async calls, not values.
  2. Callbacks suck. Sure, they enable non-blocking/async, avoiding the difficulties of pre-emptive multitasking, but suffer the deficiencies mentioned in all those promises tutorials. And they violate localization…


Comments are closed.