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!
- 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). - ".post '/save'…->": context is an Express route handler; the usual middleware, plus custom authentication and authorization, gave us req.body, req.user, etc.
- "users" is a promisified LevelDB:
db=(require 'level-promise') (require 'level-sublevel') (require 'level') 'data',keyEncoding:'utf8',valueEncoding:'json' users=db.sublevel 'users'
- There's also "mget=require 'level-mget'" which we have to promisify manually:
return (promise.denodeify mget) users,p.children
- 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. - "promise.reject false" is required to distinguish from other, unhandled exceptions.
Problems: promises suck
- Anything that needs that much explanation (google it) is broken: overcomplicated, for such a common usecase.
- I call BS on the encapsulated value excuse — it's always been a control flow issue.
- Localization is still somewhat violated?
- "You cannot really end a chain, unless you throw an exception that bubbles until its end."
Callback hell?
- 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.
- 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…
--
The real world is a special case