@missive/ndex
Version:
Ndex is an indexedDB wrapper
715 lines (562 loc) • 23.6 kB
text/coffeescript
factory = ->
Connection = {}
# Public API
class Connection.API
CONNECTION_TIMEOUT: 5000
REQUEST_TIMEOUT: 5000
constructor: (, , = {}) ->
= null
= {}
= new Connection.Logging
parseMigrations: (migrations) ->
if version = migrations.version
= version
delete migrations.version
keys = Object.keys(migrations).sort()
keys.map (key) =>
version = parseInt(key)
titleMatches = key.match(/_(.+)/)
title = if titleMatches then titleMatches[1].replace(/(\w)([A-Z])/g, ($1, $2, $3) -> "#{$2} #{$3.toLowerCase()}") else ''
actions = migrations[key] || []
actions = [actions] unless Array.isArray(actions)
{ version, title, actions, key }
deleteDatabase: ->
new Promise (resolve) =>
return resolve() unless
this.close()
request = indexedDB.deleteDatabase(.name)
request.onsuccess = (e) -> setTimeout (-> resolve()), 0
open: ({ withoutVersion } = {}) ->
return if
= new Promise (resolve, reject) =>
return reject('indexedDB isn’t supported') unless self.indexedDB
migrations = this.parseMigrations()
try
version = if withoutVersion then undefined else || migrations.length + 1
request = indexedDB.open(, version)
connectionTimeout = .connectionTimeout ?
if connectionTimeout? && connectionTimeout > -1
request.__timeout = setTimeout ->
return if request.__handled
request.__timedout = true
reject(new Error('Connection timed out'))
, connectionTimeout
catch e
clearTimeout(request.__timeout)
return if request.__timedout
request.__handled = true
return reject(e.message || e.name)
# Migrations
request.onupgradeneeded = (e) =>
clearTimeout(request.__timeout)
return if request.__timedout
request.__handled = true
db = e.target.result
transaction = e.target.transaction
migrationTransaction = new Connection.Migration(db, transaction)
migrationTransaction.createObjectStore('migrations', keyPath: 'version')
for migration in migrations
for action in migration.actions
migrationTransaction[action.type]?(action.args...)
transaction.objectStore('migrations').put(migration)
# Opened
request.onsuccess = (e) =>
clearTimeout(request.__timeout)
return if request.__timedout
request.__handled = true
db = e.target.result
objectStoreNames = [].slice.call(db.objectStoreNames)
this.createNamespaceForObjectStores(objectStoreNames)
# Same DB opened on another tab
db.onversionchange = =>
this.close()
= db
resolve(objectStoreNames)
# Error
request.onerror = (e) =>
clearTimeout(request.__timeout)
return if request.__timedout
request.__handled = true
if !withoutVersion && request.error.name == 'VersionError'
this.close()
return this.open({ withoutVersion: true })
.then(resolve).catch(reject)
reject(request.error.message || request.error.name)
close: ->
delete if
.close() if
get: (objectStoreName, key, indexName) ->
new Promise (resolve, reject) =>
if Array.isArray(key)
Promise.all(key.map (k) => this.get(objectStoreName, k))
.then(resolve)
.catch(reject)
else
this.enqueue 'read', objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'get', objectStoreName, indexName, { key: key })
params = {
method: 'get'
args: key
transaction, reject
objectStoreName, indexName
}
this.createRequest params, (e) ->
value = e.target.result
value = null if value is undefined
resolve(value)
getFirst: (objectStoreName, indexName) ->
new Promise (resolve, reject) =>
this.enqueue 'read', objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'getFirst', objectStoreName, indexName)
params = {
method: 'openCursor'
transaction, reject
objectStoreName, indexName
}
this.createRequest params, (e) ->
request = e.target
objectStore = request.source
unless cursor = request.result
return resolve(null)
value = cursor.value
value._key = cursor.key unless objectStore.keyPath
resolve(value)
getAll: (objectStoreName, indexName) ->
new Promise (resolve, reject) =>
this.enqueue 'read', objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'getAll', objectStoreName, indexName)
results = []
params = {
method: 'openCursor'
transaction, reject
objectStoreName, indexName
}
this.createRequest params, (e) ->
request = e.target
objectStore = request.source
unless cursor = e.target.result
return resolve(results)
value = cursor.value
value._key = cursor.key unless objectStore.keyPath
results.push(value)
cursor.continue()
count: (objectStoreName, indexName) ->
new Promise (resolve, reject) =>
this.enqueue 'read', objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'count', objectStoreName, indexName)
params = {
method: 'count'
transaction, reject
objectStoreName, indexName
}
this.createRequest params, (e) ->
value = e.target.result
value = 0 unless value
resolve(value)
add: (objectStoreName, key, data) ->
[key, data] = [null, key] if data is undefined
new Promise (resolve, reject) =>
if !key && Array.isArray(data)
Promise.all(data.map (d) => this.add(objectStoreName, d))
.then(resolve)
.catch(reject)
else if key && Array.isArray(key) && Array.isArray(data)
Promise.all(key.map (key, i) => this.add(objectStoreName, key, data[i]))
.then(resolve)
.catch(reject)
else
this.enqueue 'write', objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'add', objectStoreName, null, { key: key, data: data })
args = if key then [data, key] else [data]
params = {
method: 'put'
args: args
transaction, objectStoreName, reject
}
this.createRequest params, (e) ->
data._key = e.target.result
resolve(data)
update: (objectStoreName, key, value) ->
new Promise (resolve, reject) =>
this.enqueue 'write', objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'update', objectStoreName, null, { key: key, data: value })
params = {
method: 'get'
args: key
transaction, objectStoreName, reject
}
this.createRequest params, (e) =>
request = e.target
objectStore = request.source
keyPath = objectStore.keyPath
hasKeyPath = !!keyPath
data = request.result
if data is undefined
data = value
data[keyPath] = key if hasKeyPath
else
deepUpdate = (o, root) =>
for k, v of o
if typeof v is 'object'
deepUpdate(v, root[k])
continue
root[k] = v
deepUpdate(value, data)
args = if hasKeyPath then [data] else [data, key]
params = {
method: 'put'
args: args
transaction, objectStoreName, reject
}
this.createRequest params, (e) ->
resolve(data)
increment: (objectStoreName, key, value = 1, decrement) ->
new Promise (resolve, reject) =>
this.enqueue 'write', objectStoreName, reject, (transaction) =>
.addRequest(transaction, (if decrement then 'decrement' else 'increment'), objectStoreName, null, { key: key, data: value })
params = {
method: 'get'
args: key
transaction, objectStoreName, reject
}
this.createRequest params, (e) =>
request = e.target
objectStore = request.source
keyPath = objectStore.keyPath
hasKeyPath = !!keyPath
data = request.result
if hasKeyPath
deepIncrement = (o, root) ->
for k, v of o
if typeof v is 'object'
deepIncrement(v, root[k])
continue
root[k] ||= 0
root[k] += if decrement then -v else v
deepIncrement(value, data)
else
data ||= 0
data += if decrement then -value else value
args = if hasKeyPath then [data] else [data, key]
params = {
method: 'put'
args: args
transaction, objectStoreName, reject
}
this.createRequest params, (e) ->
resolve(data)
decrement: (objectStoreName, key, value) ->
this.increment(objectStoreName, key, value, true)
delete: (objectStoreName, key) ->
new Promise (resolve, reject) =>
if Array.isArray(key)
Promise.all(key.map (k) => this.delete(objectStoreName, k))
.then(resolve)
.catch(reject)
else
this.enqueue 'write', objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'delete', objectStoreName, null, { key: key })
params = {
method: 'delete'
args: key
transaction, objectStoreName, reject
}
this.createRequest params, (e) ->
resolve(key)
deleteWhere: (objectStoreName, predicates, indexName) ->
predicates.remove = true
this.where(objectStoreName, predicates, indexName)
clear: (objectStoreName) ->
new Promise (resolve, reject) =>
this.enqueue 'write', objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'clear', objectStoreName)
params = {
method: 'clear'
transaction, objectStoreName, reject
}
this.createRequest params, (e) ->
resolve()
clearAll: ->
new Promise (resolve, reject) =>
objectStoreNames = [.objectStoreNames...]
objectStoreNames = objectStoreNames.filter (objectStoreName) -> objectStoreName isnt 'migrations'
Promise.all(objectStoreNames.map (objectStoreName) => this.clear(objectStoreName))
.then(resolve)
.catch(reject)
reset: (objectStoreName, key, data) ->
new Promise (resolve, reject) =>
this.clear(objectStoreName)
.then =>
this.add(objectStoreName, key, data)
.then(resolve)
.catch(reject)
.catch(reject)
index: (objectStoreName, indexName) ->
this.createNamespaceForIndex(indexName, objectStoreName)
where: (objectStoreName, predicates, indexName) ->
readWrite = if predicates.remove then 'write' else 'read'
new Promise (resolve, reject) =>
this.enqueue readWrite, objectStoreName, reject, (transaction) =>
.addRequest(transaction, 'where', objectStoreName, indexName, { data: predicates })
{ lt, lteq, gt, gteq, eq, limit, offset, only, contains, except, uniq, order, remove } = predicates
uniques = if Array.isArray(uniq) then uniq else if uniq then [uniq] else []
order = if order is 'desc' then 'prev' else 'next'
# Bounds
bounds = {}
for k, v of { lt: lt, lteq: lteq, gt: gt, gteq: gteq, eq: eq }
continue if v is undefined
isInclusive = k is 'gteq' || k is 'lteq'
isEquivalent = k is 'eq'
isLowerBound = k is 'gt' || k is 'gteq'
bound = if isEquivalent then 'exact' else if isLowerBound then 'lower' else 'upper'
bounds[bound] =
value: v
open: !isInclusive
# Range
range = null
eqIsArray = false
{ lower, upper, exact } = bounds
if exact
if eqIsArray = Array.isArray(eq)
eq.sort()
lower = { value: eq[0], open: false }
upper = { value: eq[eq.length - 1], open: false }
range = IDBKeyRange.bound(lower.value, upper.value, lower.open, upper.open)
else
range = IDBKeyRange.only(exact.value)
else if lower and upper
range = IDBKeyRange.bound(lower.value, upper.value, lower.open, upper.open)
else if lower
range = IDBKeyRange.lowerBound(lower.value, lower.open)
else if upper
range = IDBKeyRange.upperBound(upper.value, upper.open)
# Request
results = []
count = 0
knownUniques = {}
hasValues = (object, k, v) ->
v = [v] unless Array.isArray(v)
v.indexOf(object[k]) isnt -1
args = if range then [range, order] else undefined
params = {
method: 'openCursor'
args: args
transaction, reject
objectStoreName, indexName
}
this.createRequest params, (e) =>
request = e.target
objectStore = request.source
unless cursor = e.target.result
return resolve(results)
value = cursor.value
# Only
if eqIsArray
only ||= {}
only[objectStore.keyPath] = eq
for k, v of only
return cursor.continue() unless hasValues(value, k, v)
# Contains
for k, v of contains
v = [v] unless Array.isArray(v)
a = value[k]
a = [a] unless Array.isArray(a)
return cursor.continue() unless this.intersect(a, v).length
# Except
for k, v of except
return cursor.continue() if hasValues(value, k, v)
# Unique
for k in uniques
knownValues = knownUniques[k] ||= []
if knownValues.indexOf(value[k]) isnt -1
return cursor.continue()
knownUniques[k].push(value[k])
# Offset
count++
if offset && count <= offset
return cursor.continue()
# Add value to the results
results.push(value)
cursor.delete() if remove
# Limit
if limit
if typeof limit is 'function' && limit(results)
return resolve(results)
else if limit is results.length
return resolve(results)
cursor.continue()
# Helpers
getMethodsForObjectStore: -> ||= ['get', 'getFirst', 'getAll', 'count', 'add', 'update', 'increment', 'decrement', 'delete', 'deleteWhere', 'clear', 'reset', 'index', 'where']
getMethodsForIndex: -> ||= ['get', 'getFirst', 'getAll', 'count', 'where', 'deleteWhere']
createNamespaceForObjectStores: (objectStoreNames = [], context = this) ->
for objectStoreName in objectStoreNames
continue if objectStoreName is 'migrations'
this.createNamespaceForObjectStore(objectStoreName, context)
createNamespaceForObjectStore: (objectStoreName, context = this) ->
namespace = context[objectStoreName] = {}
this.getMethodsForObjectStore().forEach (method) =>
namespace[method] = => context[method](objectStoreName, arguments...)
namespace
createNamespaceForIndex: (indexName, objectStoreName, context = this) ->
namespace = {}
this.getMethodsForIndex().forEach (method) =>
namespace[method] = => context[method](objectStoreName, arguments..., indexName)
namespace
getObjectStore: ({ transaction, objectStoreName, indexName }) ->
objectStore = transaction.objectStore(objectStoreName)
if indexName then objectStore.index(indexName) else objectStore
createRequest: ({ method, args, transaction, objectStoreName, indexName, reject }, callback) ->
objectStore = transaction.objectStore(objectStoreName)
objectStore = objectStore.index(indexName) if indexName
args = [args] unless Array.isArray(args)
request = objectStore[method](args...)
requestTimeout = .requestTimeout ?
if requestTimeout? && requestTimeout > -1
request.__timeout = setTimeout ->
return if request.__handled
request.__timedout = true
try transaction.abort()
, requestTimeout
request.onsuccess = (e) ->
clearTimeout(request.__timeout)
return if request.__timedout
request.__handled = true
callback(e)
request.onerror = (e) ->
clearTimeout(request.__timeout)
request.__handled = true
reject?(request.error)
request
createTransaction: (mode, objectStoreName, callback) ->
this.open()
.then =>
try transaction = .transaction([objectStoreName], mode)
catch error
callback(transaction)
.catch (err) =>
throw(err)
intersect: (a, b) ->
ai = 0; bi = 0; result = []
while (ai < a.length && bi < b.length)
if (a[ai] < b[bi]) then ai++
else if (a[ai] > b[bi]) then bi++
else
result.push(a[ai])
ai++; bi++
result
# Queue
enqueue: (readwrite, objectStoreName, reject, callback) ->
[objectStoreName] ||= []
request = { readwrite, objectStoreName, reject, callback }
this.scheduleTransaction(objectStoreName) unless [objectStoreName].length
[objectStoreName].push(request)
scheduleTransaction: (objectStoreName) ->
setTimeout =>
requests = [objectStoreName].splice(0)
modes = requests.map (r) -> r.readwrite
needsWriteMode = modes.some (m) -> m is 'write'
mode = if needsWriteMode then 'readwrite' else 'readonly'
this.createTransaction mode, objectStoreName, (transaction) =>
.addTransaction(transaction, objectStoreName)
for request in requests
try request.callback(transaction)
catch err then request.reject(err)
, 0
# Migration
class Connection.Migration
constructor: (, ) ->
createObjectStore: (name, options) ->
return if .objectStoreNames.contains(name)
.createObjectStore(name, options)
deleteObjectStore: (name) ->
return unless .objectStoreNames.contains(name)
.deleteObjectStore(name)
createIndex: (objectStoreName, indexName, keyPath, options) ->
objectStore = .objectStore(objectStoreName)
return if objectStore && objectStore.indexNames.contains(indexName)
objectStore.createIndex(indexName, keyPath, options)
deleteIndex: (objectStoreName, indexName) ->
objectStore = .objectStore(objectStoreName)
return unless objectStore && objectStore.indexNames.contains(indexName)
objectStore.deleteIndex(indexName)
# Logging
class Connection.Logging
constructor: ->
= []
addTransaction: (transaction, objectStoreNames) ->
return unless transaction &&
.push {
transaction: transaction
objectStoreNames: objectStoreNames
requests: []
start: Date.now()
}
callback = (e) => this.logTransaction(e.target)
transaction.onabort = callback
transaction.onerror = callback
transaction.oncomplete = callback
addRequest: (transaction, method, objectStoreName, indexName, data) ->
return unless transaction &&
queue = .filter((q) -> q.transaction is transaction)[0]
return unless queue
queue.requests.push
method: method
objectStoreName: objectStoreName
indexName: indexName
data: data
logTransaction: (transaction) ->
return unless transaction &&
queue = .filter((q) -> q.transaction is transaction)[0]
return unless queue
mode = if queue.transaction.mode is 'readwrite' then 'write' else 'read '
requestsLenght = queue.requests.length
end = Date.now()
time = end - queue.start
this.handleLog
type: 'transaction.start'
data: "Ndex: #{mode} #{queue.objectStoreNames} #{time}ms (#{requestsLenght} request#{if requestsLenght > 1 then 's' else ''})"
for request in queue.requests
{ method, objectStoreName, indexName, data } = request
{ key, data } = data if data
logs = []
switch method
when 'get'
logs = ['GET', key, 'FROM', objectStoreName]
when 'getFirst'
logs = ['GET FIRST', key, 'FROM', objectStoreName]
when 'getAll'
logs = ['GET ALL', 'FROM', objectStoreName]
when 'count'
logs = ['COUNT', 'FROM', objectStoreName]
when 'add'
logs = ['ADD', JSON.stringify(data), 'TO', objectStoreName]
logs = logs.concat(['WITH KEY', key]) if key
when 'update'
logs = ['UPDATE', key, 'FROM', objectStoreName, 'SET', JSON.stringify(data)]
when 'increment'
data = JSON.stringify(data) if typeof data is 'object'
logs = ['INCREMENT', data, 'TO KEY', key, 'FROM', objectStoreName]
when 'decrement'
data = JSON.stringify(data) if typeof data is 'object'
logs = ['DECREMENT', data, 'TO KEY', key, 'FROM', objectStoreName]
when 'delete'
logs = ['DELETE', key, 'FROM', objectStoreName]
when 'clear'
logs = ['CLEAR', objectStoreName]
when 'where'
data.limit = '[FUNCTION]' if typeof data.limit is 'function'
logs = ['WHERE', JSON.stringify(data), 'FROM', objectStoreName]
if indexName
logs = logs.concat(['INDEX', indexName])
this.handleLog
type: 'request'
data: logs.join(' ')
this.handleLog
type: 'transaction.end'
# Public API
return Connection.API
# Export
if typeof exports isnt 'undefined'
module.exports = factory()
else
this.Connection = factory()