db-to-cloud v0.7.0
db-to-cloud
Synchronize your database with a cloud drive i.e. Dropbox, Google Drive, OneDrive, Github, etc.
To use this library, add following properties to the document:
- A unique ID.
NumberorString. Two objects are treated as the same document when they have the same ID. This is usually a UUID. The ID must be a valid filename. - A revision tag.
NumberorString. If two objects have the same ID but different revision, the cloud needs to decide which should be kept and saved. For a simple use case, you can use a timestamp as the revision tag and always keep the latest object.
In following examples, we use _id and _rev as property names.
Note: In PouchDB,
_revproperty is a random string which shouldn't be used as the revision tag in db-to-cloud. You may want to create a_updatedproperty storing the timestamp of the last update.
Installation
npm
npm install db-to-cloudunpkg
<!-- export to global variable "dbToCloud" -->
<script src="https://unpkg.com/db-to-cloud/dist/db-to-cloud.min.js"></script>Usage
Setup
const {dbToCloud, drive: {google}} = require("db-to-cloud");
const sync = dbToCloud({
async onGet(id) {
return await myDB.get(id);
},
async onPut(doc) {
try {
// suppose we have a revision check in the transaction
await myDB.put(doc);
} catch (err) {
if (err.type === 'outdatedDoc') {
// the doc in the local DB is newer than the doc in the cloud
sync.put(doc._id, err.doc._rev);
}
}
},
async onDelete(id) {
try {
await myDB.delete(id);
} catch (err) {}
},
async onFirstSync() {
const cursor = myDB.getAllCursor();
while (!cursor.end()) {
const {_id, _rev} = await cursor.next();
sync.put(_id, _rev);
}
},
compareRevision(rev1, rev2) {
// if we use the timestamp as the revision tag, we can use a simple way to
// decide which wins
return rev1 - rev2;
},
async getState(drive) {
try {
return JSON.parse(localStorage.getItem(`cloudSync/${drive.name}/state`));
} catch (err) {}
},
async setState(drive, state) {
localStorage.setItem(`cloudSync/${drive.name}/state`, JSON.stringify(state));
}
});
const cloud = google({
getAccessToken: async () => {
// implement a token manager to authorize the cloud
// ...
return token;
}
});
sync.use(cloud);
await sync.init();
try {
await sync.syncNow();
} catch (err) {
if (err.name === "RequestError" && err.code === 401) {
// handle login error, revoke the token
// ...
}
}
// trigger sync every 30 minutes
let syncTimer = setInterval(sync.syncNow, 30 * 60 * 1000);Update the cloud when manipulating the database
// push the change to the cloud when manipulating on the local DB
const {_id, _rev} = await myDB.post(doc);
sync.put(_id, _rev);
// note that there is no sync.post. Technically, all documents already have an
// id before sent to the cloud.
const {_id, _rev} = await myDB.put(doc);
sync.put(_id, _rev);
const {_rev} = await myDB.delete(_id);
sync.delete(_id, _rev);Switch to a different drive
clearInterval(syncTimer);
await sync.uninit();
const newDrive = github({
owner: "eight04",
repo: "_MY_DB_DATA_",
async getAccessToken() {
// ...
return token;
}
});
sync.use(newDrive);
await sync.init();
syncTimer = setInterval(sync.syncNow, 30 * 60 * 1000);API
This module exports following properties:
dbToCloud: Initialize the sync controller.drive: A cloudName/cloudFactory map.
dbToCloud
dbToCloud({
onGet: async (id, rev) => Document,
onDelete: async (id, rev) => void,
onPut: async (document) => void,
onFirstSync: async () => void,
compareRevision: (revision1, revision2) => cmpResult: Number,
getState: async (drive) => state: Object,
setState: async (drive, state) => void,
onWarn?: (message: String) => void,
onProgress?: (progressEvent: Object) => void,
lockExpire?: Number = 60,
retryMaxAttempts?: Number = 5,
retryExp?: Number = 1.5,
retryDelay?: Number = 10
}) => sync: SyncControllerCreate a sync controller. Usage example.
onGet accepts a revision tag. However, you can ignore it and return/delete the latest one since it isn't useful to store outdated document in the cloud.
onDelete also accept a revision tag. You can use it to decide if the deletion takes place or should be ignored.
onPut is used to insert/update the document to the local database. You should check if the revision tag is compatible first.
onFirstSync is called on the first sync. You can push all local documents to the cloud in this hook.
compareRevision is used to decide which revision should be kept. If cmpResult > 0 then revision1 wins. If cmpResult < 0 then revision2 wins.
getState and setState are used to get/store the current state of the sync process. You should save the state object to a file or localStorage. If getState returns undefined then it is the first sync. drive is a cloud drive adapter instance. You can get the drive name from drive.name.
Use onWarn to collect warnings. Default: console.error
Use onProgress to collect sync progress. The type of progressEvent:
{
phase: String,
total?: Number,
loaded?: Number,
change?: {
_id,
_rev?,
action
}
}phase can be start, end, pull, or push:
start- a new sync task starts.end- the entire sync task completes.pull- start pulling a change.push- start pushing a change.
When the phase is pull or push, total and loaded indicates how many changes should be processed and how many changes completed in the current phase.
Note that change._rev is undefined in the first pull. The library doesn't know the revision until the data is fetched.
When the sync task starts, the cloud drive will be locked. However, if the process is interrupted (e.g. the browser crashed) and failed to unlock, the lock will expire after lockExpire minutes.
Before pushing files to DB, SyncController has to acquire the lock first. If the lock is in used, it will wait retryDelay * retryExp ^ n seconds and try again. If it still failes after retryMaxAttempts times, an error will be thrown.
sync.use
sync.use(cloud: CloudAdapter) => voidUse a cloud adapter.
sync.init
async sync.init() => voidInitialize the sync controller and read the state from the storage into memory.
sync.use should be called before this method otherwise it throws a drive is undefined error.
Without calling this function, sending items to sync.put, sync.delete, etc, has no effect. Documents are added to the queue only if this function is called.
This function does nothing if the controller is already initialized.
sync.uninit
async sync.uninit() => voidUninitialize the sync controller:
- Stop collecting local changes.
- Wait until all running sync task complete.
- Uninitialize the cloud.
- Save the current state.
This function does nothing if the controller is not initialized.
sync.put
sync.put(id, revision) => voidAdd a "put action" to the history queue.
sync.delete
sync.delete(id, revision) => voidAdd a "delete action" to the history queue.
sync.syncNow
async sync.syncNow(peekChanges: Boolean = true) => voidSync now. Pull remote changes from the cloud and push local changes to the cloud.
When peekChanges is true, the controller calls cloud.peekChanges to check if changes are available.
sync.isInit
sync.isInit() => BooleanCheck if the controller has been initialized.
Drives
The library includes five cloud drive adapters.
Various adapters require browser builtins (e.g. fetch, FormData, Blob). You can check how do we implement such features in Node.js by looking into the test file.
fsDrive
fsDrive({
folder: String,
getFs?: async () => fs.promises
}) => CloudAdapterThis adapter stores data to local disk.
The browser build doesn't include this adapter.
dropbox
dropbox({
getAccessToken: async () => token: String,
fetch?: Function
}) => CloudAdapterThis adapter stores data to Dropbox.
If fetch is not supplied, use global variable fetch.
github
github({
getAccessToken: async () => token: String,
owner: String,
repo: String,
fetch?: Function
}) => CloudAdapterThis adapter stores data to Github repository owner/repo.
If fetch is not supplied, use global variable fetch.
google({
getAccessToken: async () => token: String,
fetch?,
FormData?,
Blob?
}) => CloudAdapterThis adapter stores data to Google Drive.
If fetch/FormData/Blob is not supplied, use the global variable.
onedrive
onedrive({
getAccessToken: async () => token: String,
fetch?
}) => CloudAdapterThis adapter stores data to OneDrive.
If fetch is not supplied, use the global variable.
webdav
webdav({
username: String,
password: String,
url: String,
fetch?,
DOMParser?
}) => CloudAdapterThis adapter stores data to webdav.
User-defined cloud drive adapter
To create a working adapter, create an object with following methods:
init, uninit
async drive.init() => void async drive.uninit() => voidOptional. These hooks will be called when
sync.syncNow/sync.uninit. If the adapter uses a large dependency, it should be dynamically loaded ininit.Note that
.initis not called until the sync actually happens.get
async drive.get(path: String) => data: StringRead the data from the drive.
If the path doesn't exist, an error should be thrown and the
codeproperty should be a string"ENOENT"or a number404.put
async drive.put(path: String, data: String) => voidWrite the data to the drive. The drive should create parent folders automatically.
If the path already exists, it should overwrite the old file.
post
async drive.post(path: String, data: String) => voidWrite the data to the drive. The drive should create parent folders automatically.
If the path already exists, an error should be thrown and the
codeproperty should be a string"EEXIST".delete
async drive.delete(path: String) => voidDelete a file. If the path doesn't exist, this function does nothing.
list
async drive.list(path: String) => Array<filename: String>List all files in the folder. This is used on the first sync since we have to fetch all documents from the drive.
Currently, only the
docsfolder will be requested.If the path doesn't exist, it can throw an ENOENT/404 error or return an empty array.
acquireLock, releaseLock
async drive.acquireLock(expire: Number) => void async drive.releaseLock() => voidOptional. The lock is acquired when a sync task starts, and is released after the sync task completes.
If these methods are not implemented, the library uses a file-based lock
lock.jsonstoring in the drive, usingpost,delete, andgetmethods.expiredefines the lifetime of the lock. If the sync task is interrupted and the lock is never released manually, the lock should be unlocked afterexpireminutes.getMeta, putMeta
async drive.getMeta() => Object async drive.putMeta(meta: Object) => voidOptional. Save/store metadata in the server.
If these methods are not implemented, the library stores the metadata as
meta.jsonin the drive.If the metadata is not set yet i.e. the first sync,
getMetashould return an empty object{}.peekChanges
async drive.peekChanges() => BooleanOptional. Check if the cloud has been changed.
If this method is not implemented, the library
getMeta()and check if the metadata is changed.
If your adapter uses an access token, make sure to throw a proper authentication error when authentication failed. The error object should have a code property and the value must be a number 401 (the HTTP status code of the auth error). You can use the db-to-cloud/lib/request module.
Changelog
0.7.0 (Dec 7, 2021)
- Add: support webdav.
- Add: retry on acquire lock error.
- Add:
LockError.
0.6.0 (Feb 10, 2021)
- Change: initialize cloud adapter in
syncNowso adapter error will interrupt the sync process instead of crashing the entire app.
- Change: initialize cloud adapter in
0.5.0 (Sep 28, 2020)
- Breaking: drop
sync.start,sync.stop. Addsync.init,sync.uninit. - Change:
onFirstSyncwill be called fromsync.syncNowinstead ofsync.start. Which meansonFirstSyncerror will interrupt the sync process correctly. - Change: add
muteparam to dropbox uploads.
- Breaking: drop
0.4.5 (Oct 17, 2019)
- Fix: clear change cache and revision cache when stopped.
0.4.4 (Oct 17, 2019)
- Fix: local state is not cleared when stopped.
0.4.3 (Oct 15, 2019)
- Fix: cannot list all docs in onedrive.
0.4.2 (Oct 12, 2019)
- Add:
sync.isInit.
- Add:
0.4.1 (Oct 9, 2019)
- Fix: ignore
start()/stop()if already started/stopped.
- Fix: ignore
0.4.0 (Oct 4, 2019)
- Breaking: the signature of
onProgressis changed. It now receives an event object.
- Breaking: the signature of
0.3.1 (Oct 3, 2019)
- Add:
onProgresshook.
- Add:
0.3.0 (Sep 30, 2019)
- Breaking: the file structure stored in the drive is changed. Store revision tag along with the doc.
0.2.0 (Sep 29, 2019)
- Breaking: drop dropbox/github SDK.
- Change: disable babel-regenerator. The browser build now includes generator functions.
0.1.0 (Sep 24, 2019)
- First release.