blob: a98956927a6056085d10c815a947fe771de9c81e [file] [log] [blame]
import {
guardedConsole,
toPromise,
hasLocalStorage,
uuid,
nextTick
} from 'pouchdb-utils';
import {
isDeleted,
isLocalId,
traverseRevTree,
winningRev as calculateWinningRev,
latest as getLatest
} from 'pouchdb-merge';
import idbBulkDocs from './bulkDocs';
import idbAllDocs from './allDocs';
import checkBlobSupport from './blobSupport';
import countDocs from './countDocs';
import {
MISSING_DOC,
REV_CONFLICT,
IDB_ERROR,
createError
} from 'pouchdb-errors';
import {
ADAPTER_VERSION,
ATTACH_AND_SEQ_STORE,
ATTACH_STORE,
BY_SEQ_STORE,
DETECT_BLOB_SUPPORT_STORE,
DOC_STORE,
LOCAL_STORE,
META_STORE
} from './constants';
import {
compactRevs,
decodeDoc,
decodeMetadata,
encodeMetadata,
idbError,
readBlobData,
openTransactionSafely
} from './utils';
import { enqueueTask } from './taskQueue';
import changesHandler from './changesHandler';
import changes from './changes';
var cachedDBs = new Map();
var blobSupportPromise;
var openReqList = new Map();
function IdbPouch(opts, callback) {
var api = this;
enqueueTask(function (thisCallback) {
init(api, opts, thisCallback);
}, callback, api.constructor);
}
function init(api, opts, callback) {
var dbName = opts.name;
var idb = null;
api._meta = null;
// called when creating a fresh new database
function createSchema(db) {
var docStore = db.createObjectStore(DOC_STORE, {keyPath : 'id'});
db.createObjectStore(BY_SEQ_STORE, {autoIncrement: true})
.createIndex('_doc_id_rev', '_doc_id_rev', {unique: true});
db.createObjectStore(ATTACH_STORE, {keyPath: 'digest'});
db.createObjectStore(META_STORE, {keyPath: 'id', autoIncrement: false});
db.createObjectStore(DETECT_BLOB_SUPPORT_STORE);
// added in v2
docStore.createIndex('deletedOrLocal', 'deletedOrLocal', {unique : false});
// added in v3
db.createObjectStore(LOCAL_STORE, {keyPath: '_id'});
// added in v4
var attAndSeqStore = db.createObjectStore(ATTACH_AND_SEQ_STORE,
{autoIncrement: true});
attAndSeqStore.createIndex('seq', 'seq');
attAndSeqStore.createIndex('digestSeq', 'digestSeq', {unique: true});
}
// migration to version 2
// unfortunately "deletedOrLocal" is a misnomer now that we no longer
// store local docs in the main doc-store, but whaddyagonnado
function addDeletedOrLocalIndex(txn, callback) {
var docStore = txn.objectStore(DOC_STORE);
docStore.createIndex('deletedOrLocal', 'deletedOrLocal', {unique : false});
docStore.openCursor().onsuccess = function (event) {
var cursor = event.target.result;
if (cursor) {
var metadata = cursor.value;
var deleted = isDeleted(metadata);
metadata.deletedOrLocal = deleted ? "1" : "0";
docStore.put(metadata);
cursor.continue();
} else {
callback();
}
};
}
// migration to version 3 (part 1)
function createLocalStoreSchema(db) {
db.createObjectStore(LOCAL_STORE, {keyPath: '_id'})
.createIndex('_doc_id_rev', '_doc_id_rev', {unique: true});
}
// migration to version 3 (part 2)
function migrateLocalStore(txn, cb) {
var localStore = txn.objectStore(LOCAL_STORE);
var docStore = txn.objectStore(DOC_STORE);
var seqStore = txn.objectStore(BY_SEQ_STORE);
var cursor = docStore.openCursor();
cursor.onsuccess = function (event) {
var cursor = event.target.result;
if (cursor) {
var metadata = cursor.value;
var docId = metadata.id;
var local = isLocalId(docId);
var rev = calculateWinningRev(metadata);
if (local) {
var docIdRev = docId + "::" + rev;
// remove all seq entries
// associated with this docId
var start = docId + "::";
var end = docId + "::~";
var index = seqStore.index('_doc_id_rev');
var range = IDBKeyRange.bound(start, end, false, false);
var seqCursor = index.openCursor(range);
seqCursor.onsuccess = function (e) {
seqCursor = e.target.result;
if (!seqCursor) {
// done
docStore.delete(cursor.primaryKey);
cursor.continue();
} else {
var data = seqCursor.value;
if (data._doc_id_rev === docIdRev) {
localStore.put(data);
}
seqStore.delete(seqCursor.primaryKey);
seqCursor.continue();
}
};
} else {
cursor.continue();
}
} else if (cb) {
cb();
}
};
}
// migration to version 4 (part 1)
function addAttachAndSeqStore(db) {
var attAndSeqStore = db.createObjectStore(ATTACH_AND_SEQ_STORE,
{autoIncrement: true});
attAndSeqStore.createIndex('seq', 'seq');
attAndSeqStore.createIndex('digestSeq', 'digestSeq', {unique: true});
}
// migration to version 4 (part 2)
function migrateAttsAndSeqs(txn, callback) {
var seqStore = txn.objectStore(BY_SEQ_STORE);
var attStore = txn.objectStore(ATTACH_STORE);
var attAndSeqStore = txn.objectStore(ATTACH_AND_SEQ_STORE);
// need to actually populate the table. this is the expensive part,
// so as an optimization, check first that this database even
// contains attachments
var req = attStore.count();
req.onsuccess = function (e) {
var count = e.target.result;
if (!count) {
return callback(); // done
}
seqStore.openCursor().onsuccess = function (e) {
var cursor = e.target.result;
if (!cursor) {
return callback(); // done
}
var doc = cursor.value;
var seq = cursor.primaryKey;
var atts = Object.keys(doc._attachments || {});
var digestMap = {};
for (var j = 0; j < atts.length; j++) {
var att = doc._attachments[atts[j]];
digestMap[att.digest] = true; // uniq digests, just in case
}
var digests = Object.keys(digestMap);
for (j = 0; j < digests.length; j++) {
var digest = digests[j];
attAndSeqStore.put({
seq: seq,
digestSeq: digest + '::' + seq
});
}
cursor.continue();
};
};
}
// migration to version 5
// Instead of relying on on-the-fly migration of metadata,
// this brings the doc-store to its modern form:
// - metadata.winningrev
// - metadata.seq
// - stringify the metadata when storing it
function migrateMetadata(txn) {
function decodeMetadataCompat(storedObject) {
if (!storedObject.data) {
// old format, when we didn't store it stringified
storedObject.deleted = storedObject.deletedOrLocal === '1';
return storedObject;
}
return decodeMetadata(storedObject);
}
// ensure that every metadata has a winningRev and seq,
// which was previously created on-the-fly but better to migrate
var bySeqStore = txn.objectStore(BY_SEQ_STORE);
var docStore = txn.objectStore(DOC_STORE);
var cursor = docStore.openCursor();
cursor.onsuccess = function (e) {
var cursor = e.target.result;
if (!cursor) {
return; // done
}
var metadata = decodeMetadataCompat(cursor.value);
metadata.winningRev = metadata.winningRev ||
calculateWinningRev(metadata);
function fetchMetadataSeq() {
// metadata.seq was added post-3.2.0, so if it's missing,
// we need to fetch it manually
var start = metadata.id + '::';
var end = metadata.id + '::\uffff';
var req = bySeqStore.index('_doc_id_rev').openCursor(
IDBKeyRange.bound(start, end));
var metadataSeq = 0;
req.onsuccess = function (e) {
var cursor = e.target.result;
if (!cursor) {
metadata.seq = metadataSeq;
return onGetMetadataSeq();
}
var seq = cursor.primaryKey;
if (seq > metadataSeq) {
metadataSeq = seq;
}
cursor.continue();
};
}
function onGetMetadataSeq() {
var metadataToStore = encodeMetadata(metadata,
metadata.winningRev, metadata.deleted);
var req = docStore.put(metadataToStore);
req.onsuccess = function () {
cursor.continue();
};
}
if (metadata.seq) {
return onGetMetadataSeq();
}
fetchMetadataSeq();
};
}
api.type = function () {
return 'idb';
};
api._id = toPromise(function (callback) {
callback(null, api._meta.instanceId);
});
api._bulkDocs = function idb_bulkDocs(req, reqOpts, callback) {
idbBulkDocs(opts, req, reqOpts, api, idb, callback);
};
// First we look up the metadata in the ids database, then we fetch the
// current revision(s) from the by sequence store
api._get = function idb_get(id, opts, callback) {
var doc;
var metadata;
var err;
var txn = opts.ctx;
if (!txn) {
var txnResult = openTransactionSafely(idb,
[DOC_STORE, BY_SEQ_STORE, ATTACH_STORE], 'readonly');
if (txnResult.error) {
return callback(txnResult.error);
}
txn = txnResult.txn;
}
function finish() {
callback(err, {doc: doc, metadata: metadata, ctx: txn});
}
txn.objectStore(DOC_STORE).get(id).onsuccess = function (e) {
metadata = decodeMetadata(e.target.result);
// we can determine the result here if:
// 1. there is no such document
// 2. the document is deleted and we don't ask about specific rev
// When we ask with opts.rev we expect the answer to be either
// doc (possibly with _deleted=true) or missing error
if (!metadata) {
err = createError(MISSING_DOC, 'missing');
return finish();
}
var rev;
if(!opts.rev) {
rev = metadata.winningRev;
var deleted = isDeleted(metadata);
if (deleted) {
err = createError(MISSING_DOC, "deleted");
return finish();
}
} else {
rev = opts.latest ? getLatest(opts.rev, metadata) : opts.rev;
}
var objectStore = txn.objectStore(BY_SEQ_STORE);
var key = metadata.id + '::' + rev;
objectStore.index('_doc_id_rev').get(key).onsuccess = function (e) {
doc = e.target.result;
if (doc) {
doc = decodeDoc(doc);
}
if (!doc) {
err = createError(MISSING_DOC, 'missing');
return finish();
}
finish();
};
};
};
api._getAttachment = function (docId, attachId, attachment, opts, callback) {
var txn;
if (opts.ctx) {
txn = opts.ctx;
} else {
var txnResult = openTransactionSafely(idb,
[DOC_STORE, BY_SEQ_STORE, ATTACH_STORE], 'readonly');
if (txnResult.error) {
return callback(txnResult.error);
}
txn = txnResult.txn;
}
var digest = attachment.digest;
var type = attachment.content_type;
txn.objectStore(ATTACH_STORE).get(digest).onsuccess = function (e) {
var body = e.target.result.body;
readBlobData(body, type, opts.binary, function (blobData) {
callback(null, blobData);
});
};
};
api._info = function idb_info(callback) {
var updateSeq;
var docCount;
var txnResult = openTransactionSafely(idb, [META_STORE, BY_SEQ_STORE], 'readonly');
if (txnResult.error) {
return callback(txnResult.error);
}
var txn = txnResult.txn;
txn.objectStore(META_STORE).get(META_STORE).onsuccess = function (e) {
docCount = e.target.result.docCount;
};
txn.objectStore(BY_SEQ_STORE).openCursor(null, 'prev').onsuccess = function (e) {
var cursor = e.target.result;
updateSeq = cursor ? cursor.key : 0;
};
txn.oncomplete = function () {
callback(null, {
doc_count: docCount,
update_seq: updateSeq,
// for debugging
idb_attachment_format: (api._meta.blobSupport ? 'binary' : 'base64')
});
};
};
api._allDocs = function idb_allDocs(opts, callback) {
idbAllDocs(opts, idb, callback);
};
api._changes = function idbChanges(opts) {
changes(opts, api, dbName, idb);
};
api._close = function (callback) {
// https://developer.mozilla.org/en-US/docs/IndexedDB/IDBDatabase#close
// "Returns immediately and closes the connection in a separate thread..."
idb.close();
cachedDBs.delete(dbName);
callback();
};
api._getRevisionTree = function (docId, callback) {
var txnResult = openTransactionSafely(idb, [DOC_STORE], 'readonly');
if (txnResult.error) {
return callback(txnResult.error);
}
var txn = txnResult.txn;
var req = txn.objectStore(DOC_STORE).get(docId);
req.onsuccess = function (event) {
var doc = decodeMetadata(event.target.result);
if (!doc) {
callback(createError(MISSING_DOC));
} else {
callback(null, doc.rev_tree);
}
};
};
// This function removes revisions of document docId
// which are listed in revs and sets this document
// revision to to rev_tree
api._doCompaction = function (docId, revs, callback) {
var stores = [
DOC_STORE,
BY_SEQ_STORE,
ATTACH_STORE,
ATTACH_AND_SEQ_STORE
];
var txnResult = openTransactionSafely(idb, stores, 'readwrite');
if (txnResult.error) {
return callback(txnResult.error);
}
var txn = txnResult.txn;
var docStore = txn.objectStore(DOC_STORE);
docStore.get(docId).onsuccess = function (event) {
var metadata = decodeMetadata(event.target.result);
traverseRevTree(metadata.rev_tree, function (isLeaf, pos,
revHash, ctx, opts) {
var rev = pos + '-' + revHash;
if (revs.indexOf(rev) !== -1) {
opts.status = 'missing';
}
});
compactRevs(revs, docId, txn);
var winningRev = metadata.winningRev;
var deleted = metadata.deleted;
txn.objectStore(DOC_STORE).put(
encodeMetadata(metadata, winningRev, deleted));
};
txn.onabort = idbError(callback);
txn.oncomplete = function () {
callback();
};
};
api._getLocal = function (id, callback) {
var txnResult = openTransactionSafely(idb, [LOCAL_STORE], 'readonly');
if (txnResult.error) {
return callback(txnResult.error);
}
var tx = txnResult.txn;
var req = tx.objectStore(LOCAL_STORE).get(id);
req.onerror = idbError(callback);
req.onsuccess = function (e) {
var doc = e.target.result;
if (!doc) {
callback(createError(MISSING_DOC));
} else {
delete doc['_doc_id_rev']; // for backwards compat
callback(null, doc);
}
};
};
api._putLocal = function (doc, opts, callback) {
if (typeof opts === 'function') {
callback = opts;
opts = {};
}
delete doc._revisions; // ignore this, trust the rev
var oldRev = doc._rev;
var id = doc._id;
if (!oldRev) {
doc._rev = '0-1';
} else {
doc._rev = '0-' + (parseInt(oldRev.split('-')[1], 10) + 1);
}
var tx = opts.ctx;
var ret;
if (!tx) {
var txnResult = openTransactionSafely(idb, [LOCAL_STORE], 'readwrite');
if (txnResult.error) {
return callback(txnResult.error);
}
tx = txnResult.txn;
tx.onerror = idbError(callback);
tx.oncomplete = function () {
if (ret) {
callback(null, ret);
}
};
}
var oStore = tx.objectStore(LOCAL_STORE);
var req;
if (oldRev) {
req = oStore.get(id);
req.onsuccess = function (e) {
var oldDoc = e.target.result;
if (!oldDoc || oldDoc._rev !== oldRev) {
callback(createError(REV_CONFLICT));
} else { // update
var req = oStore.put(doc);
req.onsuccess = function () {
ret = {ok: true, id: doc._id, rev: doc._rev};
if (opts.ctx) { // return immediately
callback(null, ret);
}
};
}
};
} else { // new doc
req = oStore.add(doc);
req.onerror = function (e) {
// constraint error, already exists
callback(createError(REV_CONFLICT));
e.preventDefault(); // avoid transaction abort
e.stopPropagation(); // avoid transaction onerror
};
req.onsuccess = function () {
ret = {ok: true, id: doc._id, rev: doc._rev};
if (opts.ctx) { // return immediately
callback(null, ret);
}
};
}
};
api._removeLocal = function (doc, opts, callback) {
if (typeof opts === 'function') {
callback = opts;
opts = {};
}
var tx = opts.ctx;
if (!tx) {
var txnResult = openTransactionSafely(idb, [LOCAL_STORE], 'readwrite');
if (txnResult.error) {
return callback(txnResult.error);
}
tx = txnResult.txn;
tx.oncomplete = function () {
if (ret) {
callback(null, ret);
}
};
}
var ret;
var id = doc._id;
var oStore = tx.objectStore(LOCAL_STORE);
var req = oStore.get(id);
req.onerror = idbError(callback);
req.onsuccess = function (e) {
var oldDoc = e.target.result;
if (!oldDoc || oldDoc._rev !== doc._rev) {
callback(createError(MISSING_DOC));
} else {
oStore.delete(id);
ret = {ok: true, id: id, rev: '0-0'};
if (opts.ctx) { // return immediately
callback(null, ret);
}
}
};
};
api._destroy = function (opts, callback) {
changesHandler.removeAllListeners(dbName);
//Close open request for "dbName" database to fix ie delay.
var openReq = openReqList.get(dbName);
if (openReq && openReq.result) {
openReq.result.close();
cachedDBs.delete(dbName);
}
var req = indexedDB.deleteDatabase(dbName);
req.onsuccess = function () {
//Remove open request from the list.
openReqList.delete(dbName);
if (hasLocalStorage() && (dbName in localStorage)) {
delete localStorage[dbName];
}
callback(null, { 'ok': true });
};
req.onerror = idbError(callback);
};
var cached = cachedDBs.get(dbName);
if (cached) {
idb = cached.idb;
api._meta = cached.global;
return nextTick(function () {
callback(null, api);
});
}
var req;
if (opts.storage) {
req = tryStorageOption(dbName, opts.storage);
} else {
req = indexedDB.open(dbName, ADAPTER_VERSION);
}
openReqList.set(dbName, req);
req.onupgradeneeded = function (e) {
var db = e.target.result;
if (e.oldVersion < 1) {
return createSchema(db); // new db, initial schema
}
// do migrations
var txn = e.currentTarget.transaction;
// these migrations have to be done in this function, before
// control is returned to the event loop, because IndexedDB
if (e.oldVersion < 3) {
createLocalStoreSchema(db); // v2 -> v3
}
if (e.oldVersion < 4) {
addAttachAndSeqStore(db); // v3 -> v4
}
var migrations = [
addDeletedOrLocalIndex, // v1 -> v2
migrateLocalStore, // v2 -> v3
migrateAttsAndSeqs, // v3 -> v4
migrateMetadata // v4 -> v5
];
var i = e.oldVersion;
function next() {
var migration = migrations[i - 1];
i++;
if (migration) {
migration(txn, next);
}
}
next();
};
req.onsuccess = function (e) {
idb = e.target.result;
idb.onversionchange = function () {
idb.close();
cachedDBs.delete(dbName);
};
idb.onabort = function (e) {
guardedConsole('error', 'Database has a global failure', e.target.error);
idb.close();
cachedDBs.delete(dbName);
};
// Do a few setup operations (in parallel as much as possible):
// 1. Fetch meta doc
// 2. Check blob support
// 3. Calculate docCount
// 4. Generate an instanceId if necessary
// 5. Store docCount and instanceId on meta doc
var txn = idb.transaction([
META_STORE,
DETECT_BLOB_SUPPORT_STORE,
DOC_STORE
], 'readwrite');
var storedMetaDoc = false;
var metaDoc;
var docCount;
var blobSupport;
var instanceId;
function completeSetup() {
if (typeof blobSupport === 'undefined' || !storedMetaDoc) {
return;
}
api._meta = {
name: dbName,
instanceId: instanceId,
blobSupport: blobSupport
};
cachedDBs.set(dbName, {
idb: idb,
global: api._meta
});
callback(null, api);
}
function storeMetaDocIfReady() {
if (typeof docCount === 'undefined' || typeof metaDoc === 'undefined') {
return;
}
var instanceKey = dbName + '_id';
if (instanceKey in metaDoc) {
instanceId = metaDoc[instanceKey];
} else {
metaDoc[instanceKey] = instanceId = uuid();
}
metaDoc.docCount = docCount;
txn.objectStore(META_STORE).put(metaDoc);
}
//
// fetch or generate the instanceId
//
txn.objectStore(META_STORE).get(META_STORE).onsuccess = function (e) {
metaDoc = e.target.result || { id: META_STORE };
storeMetaDocIfReady();
};
//
// countDocs
//
countDocs(txn, function (count) {
docCount = count;
storeMetaDocIfReady();
});
//
// check blob support
//
if (!blobSupportPromise) {
// make sure blob support is only checked once
blobSupportPromise = checkBlobSupport(txn);
}
blobSupportPromise.then(function (val) {
blobSupport = val;
completeSetup();
});
// only when the metadata put transaction has completed,
// consider the setup done
txn.oncomplete = function () {
storedMetaDoc = true;
completeSetup();
};
};
req.onerror = function () {
var msg = 'Failed to open indexedDB, are you in private browsing mode?';
guardedConsole('error', msg);
callback(createError(IDB_ERROR, msg));
};
}
IdbPouch.valid = function () {
// Issue #2533, we finally gave up on doing bug
// detection instead of browser sniffing. Safari brought us
// to our knees.
var isSafari = typeof openDatabase !== 'undefined' &&
/(Safari|iPhone|iPad|iPod)/.test(navigator.userAgent) &&
!/Chrome/.test(navigator.userAgent) &&
!/BlackBerry/.test(navigator.platform);
// some outdated implementations of IDB that appear on Samsung
// and HTC Android devices <4.4 are missing IDBKeyRange
return !isSafari && typeof indexedDB !== 'undefined' &&
typeof IDBKeyRange !== 'undefined';
};
function tryStorageOption(dbName, storage) {
try { // option only available in Firefox 26+
return indexedDB.open(dbName, {
version: ADAPTER_VERSION,
storage: storage
});
} catch(err) {
return indexedDB.open(dbName, ADAPTER_VERSION);
}
}
export default function (PouchDB) {
PouchDB.adapter('idb', IdbPouch, true);
}