class app.DB NAME = 'docs' VERSION = 15 constructor: -> @versionMultipler = if $.isIE() then 1e5 else 1e9 @useIndexedDB = @useIndexedDB() @callbacks = [] db: (fn) -> return fn() unless @useIndexedDB @callbacks.push(fn) if fn return if @open try @open = true req = indexedDB.open(NAME, VERSION * @versionMultipler + @userVersion()) req.onsuccess = @onOpenSuccess req.onerror = @onOpenError req.onupgradeneeded = @onUpgradeNeeded catch error @fail 'exception', error return onOpenSuccess: (event) => db = event.target.result if db.objectStoreNames.length is 0 try db.close() @open = false @fail 'empty' else if error = @buggyIDB(db) try db.close() @open = false @fail 'buggy', error else @runCallbacks(db) @open = false db.close() return onOpenError: (event) => event.preventDefault() @open = false error = event.target.error switch error.name when 'QuotaExceededError' @onQuotaExceededError() when 'VersionError' @onVersionError() when 'InvalidStateError' @fail 'private_mode' else @fail 'cant_open', error return fail: (reason, error) -> @cachedDocs = null @useIndexedDB = false @reason or= reason @error or= error console.error? 'IDB error', error if error @runCallbacks() if error and reason is 'cant_open' Raven.captureMessage "#{error.name}: #{error.message}", level: 'warning', fingerprint: [error.name] return onQuotaExceededError: -> @reset() @db() app.onQuotaExceeded() Raven.captureMessage 'QuotaExceededError', level: 'warning' return onVersionError: -> req = indexedDB.open(NAME) req.onsuccess = (event) => @handleVersionMismatch event.target.result.version req.onerror = (event) -> event.preventDefault() @fail 'cant_open', error return handleVersionMismatch: (actualVersion) -> if Math.floor(actualVersion / @versionMultipler) isnt VERSION @fail 'version' else @setUserVersion actualVersion - VERSION * @versionMultipler @db() return buggyIDB: (db) -> return if @checkedBuggyIDB @checkedBuggyIDB = true try @idbTransaction(db, stores: $.makeArray(db.objectStoreNames)[0..1], mode: 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937 return catch error return error runCallbacks: (db) -> fn(db) while fn = @callbacks.shift() return onUpgradeNeeded: (event) -> return unless db = event.target.result objectStoreNames = $.makeArray(db.objectStoreNames) unless $.arrayDelete(objectStoreNames, 'docs') try db.createObjectStore('docs') for doc in app.docs.all() when not $.arrayDelete(objectStoreNames, doc.slug) try db.createObjectStore(doc.slug) for name in objectStoreNames try db.deleteObjectStore(name) return store: (doc, data, onSuccess, onError, _retry = true) -> @db (db) => unless db onError() return txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false txn.oncomplete = => @cachedDocs?[doc.slug] = doc.mtime onSuccess() return txn.onerror = (event) => event.preventDefault() if txn.error?.name is 'NotFoundError' and _retry @migrate() setTimeout => @store(doc, data, onSuccess, onError, false) , 0 else onError(event) return store = txn.objectStore(doc.slug) store.clear() store.add(content, path) for path, content of data store = txn.objectStore('docs') store.put(doc.mtime, doc.slug) return return unstore: (doc, onSuccess, onError, _retry = true) -> @db (db) => unless db onError() return txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false txn.oncomplete = => delete @cachedDocs?[doc.slug] onSuccess() return txn.onerror = (event) -> event.preventDefault() if txn.error?.name is 'NotFoundError' and _retry @migrate() setTimeout => @unstore(doc, onSuccess, onError, false) , 0 else onError(event) return store = txn.objectStore('docs') store.delete(doc.slug) store = txn.objectStore(doc.slug) store.clear() return return version: (doc, fn) -> if (version = @cachedVersion(doc))? fn(version) return @db (db) => unless db fn(false) return txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' store = txn.objectStore('docs') req = store.get(doc.slug) req.onsuccess = -> fn(req.result) return req.onerror = (event) -> event.preventDefault() fn(false) return return return cachedVersion: (doc) -> return unless @cachedDocs @cachedDocs[doc.slug] or false versions: (docs, fn) -> if versions = @cachedVersions(docs) fn(versions) return @db (db) => unless db fn(false) return txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' txn.oncomplete = -> fn(result) return store = txn.objectStore('docs') result = {} docs.forEach (doc) -> req = store.get(doc.slug) req.onsuccess = -> result[doc.slug] = req.result return req.onerror = (event) -> event.preventDefault() result[doc.slug] = false return return return cachedVersions: (docs) -> return unless @cachedDocs result = {} result[doc.slug] = @cachedVersion(doc) for doc in docs result load: (entry, onSuccess, onError) -> if @shouldLoadWithIDB(entry) onError = @loadWithXHR.bind(@, entry, onSuccess, onError) @loadWithIDB entry, onSuccess, onError else @loadWithXHR entry, onSuccess, onError loadWithXHR: (entry, onSuccess, onError) -> ajax url: entry.fileUrl() dataType: 'html' success: onSuccess error: onError loadWithIDB: (entry, onSuccess, onError) -> @db (db) => unless db onError() return unless db.objectStoreNames.contains(entry.doc.slug) onError() @loadDocsCache(db) return txn = @idbTransaction db, stores: [entry.doc.slug], mode: 'readonly' store = txn.objectStore(entry.doc.slug) req = store.get(entry.dbPath()) req.onsuccess = -> if req.result then onSuccess(req.result) else onError() return req.onerror = (event) -> event.preventDefault() onError() return @loadDocsCache(db) return loadDocsCache: (db) -> return if @cachedDocs @cachedDocs = {} txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' txn.oncomplete = => setTimeout(@checkForCorruptedDocs, 50) return req = txn.objectStore('docs').openCursor() req.onsuccess = (event) => return unless cursor = event.target.result @cachedDocs[cursor.key] = cursor.value cursor.continue() return req.onerror = (event) -> event.preventDefault() return return checkForCorruptedDocs: => @db (db) => @corruptedDocs = [] docs = (key for key, value of @cachedDocs when value) return if docs.length is 0 for slug in docs when not app.docs.findBy('slug', slug) @corruptedDocs.push(slug) for slug in @corruptedDocs $.arrayDelete(docs, slug) if docs.length is 0 setTimeout(@deleteCorruptedDocs, 0) return txn = @idbTransaction(db, stores: docs, mode: 'readonly', ignoreError: false) txn.oncomplete = => setTimeout(@deleteCorruptedDocs, 0) if @corruptedDocs.length > 0 return for doc in docs txn.objectStore(doc).get('index').onsuccess = (event) => @corruptedDocs.push(event.target.source.name) unless event.target.result return return return deleteCorruptedDocs: => @db (db) => txn = @idbTransaction(db, stores: ['docs'], mode: 'readwrite', ignoreError: false) store = txn.objectStore('docs') while doc = @corruptedDocs.pop() @cachedDocs[doc] = false store.delete(doc) return Raven.captureMessage 'corruptedDocs', level: 'info', extra: { docs: @corruptedDocs.join(',') } return shouldLoadWithIDB: (entry) -> @useIndexedDB and (not @cachedDocs or @cachedDocs[entry.doc.slug]) idbTransaction: (db, options) -> app.lastIDBTransaction = [options.stores, options.mode] txn = db.transaction(options.stores, options.mode) unless options.ignoreError is false txn.onerror = (event) -> event.preventDefault() return unless options.ignoreAbort is false txn.onabort = (event) -> event.preventDefault() return txn reset: -> try indexedDB?.deleteDatabase(NAME) catch return useIndexedDB: -> try if !app.isSingleDoc() and window.indexedDB true else @reason = 'not_supported' false catch false migrate: -> app.settings.set('schema', @userVersion() + 1) return setUserVersion: (version) -> app.settings.set('schema', version) return userVersion: -> app.settings.get('schema')