Table Of Contents
- 1 Introduction
- 2 CouchDb
- 3 Quasar Project
- 4 Preparing
- 5 Configuring PouchdDb
- 6 CouchDb
- 7 Centralized Data
- 8 Setting the Framework
- 9 Listing the People
- 10 Editing a Person
- 11 Wrapping the PouchDB instance with a Worker
- 12 Syncing when the App is closed
- 13 Repository
1 Introduction
We'll build an SSR app that will manage a small CRUD, but the whole CRUD will work offline. To be able of to do that, we'll use PouchDB to persist everything at the client's browser. Then, on the server side, we'll directly query the CouchDB.
We'll use a Quasar app extension that will help us to create the stores and the pages we'll need. If you want to read more about app extensions, check the follow link: Quasar - Utility Belt App Extension to speedup the development of SSR and offline first apps.
2 CouchDb
Our first step, is to install a CouchDb Instance. Go to CouchDb Home Page and follow the instructions.
The exact step-by-steps to install CouchDB will depend of your OS. If you're on Windows
, it will be as simple as next > next > finish
wizard. If you're on Linux
, you'll need to execute some commands in your terminal. That will take some time, but you should be used to it.
To check if everything is working as expected, you would access: http://localhost:5984/_utils, a page like the below one will appear.
3 Quasar Project
First of all, I really recommend you to use yarn
to manage your local packages and npm
for the global ones, but you're free to use your preferred package manager.
Our first step is make sure the @quasar/cli
is installed and up-to-date
, so even if you already have the cli installed, please run the follow command.
$ npm i -g @quasar/cli@latest
We can now create a new project, run the following command:
$ quasar create quasar-offline
here is what I selected:
? Project name (internal usage for dev) quasar-offline
? Project product name (official name; must start with a letter if you will build mobile apps) Quasar App
? Project description A Quasar Framework app
? Author Tobias de Abreu Mesquita <tobias.mesquita@gmail.com>
? Check the features needed for your project: (Press <space> to select, <a> to toggle all, <i> to invert selection)ESLint, Vuex, Axios, Vue-i18n
? Pick an ESLint preset Standard
? Cordova id (disregard if not building mobile apps) org.cordova.quasar.app
? Should we run `npm install` for you after the project has been created? (recommended) yarn
Besides the Vuex feature, you aren't bound to any of those options, so feel free to select what you might already do normally.
4 Preparing
4.1 Utility Belt App Extension
$ quasar ext add "@toby.mosque/utils"
4.2 Installing dependencies
Since we're planning to use the PouchDB to persist everything at the client-side, we need to install the required packages.
$ yarn add pouchdb pouchdb-find relational-pouch worker-pouch
4.3 Setup
We need to do a few small changes in the project (ok, we'll do a workaround/macgyver).
Edit your ./babel.config.js
to look like:
module.exports = {
presets: [
'@quasar/babel-preset-app'
]
}
Open your ./quasar.conf.js
and extend the webpack with the follow line:
cfg.resolve.alias['pouchdb-promise'] = path.join(__dirname, '/node_modules/pouchdb-promise/lib/index.js')
Here a simplified view of the ./quasar.conf.js
.
const path = require('path')
module.exports = function (ctx) {
return {
build: {
extendWebpack (cfg) {
cfg.resolve.alias['pouchdb-promise'] = path.join(__dirname, '/node_modules/pouchdb-promise/lib/index.js')
}
}
}
}
5 Configuring PouchdDb
5.1 Creating a Boot File
Following the Quasar's philosophy, in order to configure anything, you would create a boot with that single responsibility.
$ quasar new boot pouchdb/index
You need to register the boot file in the ./quasar.conf.js
const path = require('path')
module.exports = function (ctx) {
return {
boot: [
'i18n',
'axios',
'pouchdb/index'
]
}
}
5.2 Installing the PouchDb plugins
We'll install the pouchdb's plugins in a separated file:
Create ./src/boot/pouchdb/setup.js
and modify it to look like this:
import PouchDB from 'pouchdb'
import RelationalPouch from 'relational-pouch'
import PouchDbFind from 'pouchdb-find'
import WorkerPouch from 'worker-pouch'
PouchDB.adapter('worker', WorkerPouch)
PouchDB.plugin(RelationalPouch)
PouchDB.plugin(PouchDbFind)
export default PouchDB
Now, edit the ./src/boot/pouchdb/index.js
import PouchDB from './setup'
class Database {
local = void 0
remote = void 0
syncHandler = void 0
async configure ({ isSSR }) {
if (isSSR) {
this.local = new PouchDB('http://localhost:5984/master/')
} else {
this.local = new PouchDB('db')
this.remote = new PouchDB('http://localhost:5984/master/')
}
}
}
const db = new Database()
export default async ({ Vue, ssrContext }) => {
await db.configure({ isSSR: !!ssrContext })
Vue.prototype.$db = db
}
export { db }
What are we doing here? We need a slightly different behavior when the code is running at the client-side when compared to the server-side.
When at the server-side, the app will query the CouchDb instance directly.
When at the client-side, the app will rely only on the local database and sync whenever a connection is available.
5.3 Configuring your database schema
One of the common mistakes what devs do when starting with PouchDb
/CouchDb
, is create a table for each doc type (based on personal experience), but soon they will figure out that this isn't a good idea. Each database needs a dedicated connection in order to sync properly.
To solve that problem, we will persist everything in a single table. Personally, I believe it is easy to think about the data in a relational way, so we'll use a PouchDB plugin to abstract that: relational-pouch
We already registered the plugin in the previous step, but we still need to configure the database schema. Again, we'll do that in a separate file:
Create ./src/boot/pouchdb/create.js
and modify it to look like this:
import PouchDB from './setup'
export default function (name, options) {
let db = options !== void 0 ? new PouchDB(name, options) : new PouchDB(name)
db.setSchema([
{
singular: 'person',
plural: 'people',
relations: {
company: { belongsTo: { type: 'company', options: { async: true } } },
job: { belongsTo: { type: 'job', options: { async: true } } }
}
},
{
singular: 'company',
plural: 'companies',
relations: {
people: { hasMany: { type: 'person', options: { async: true, queryInverse: 'person' } } }
}
},
{
singular: 'job',
plural: 'jobs',
relations: {
people: { hasMany: { type: 'person', options: { async: true, queryInverse: 'person' } } }
}
}
])
return db
}
One more time, edit the ./src/boot/pouchdb/index.js
import create from './create'
class Database {
local = void 0
remote = void 0
syncHandler = void 0
async configure ({ isSSR }) {
if (isSSR) {
this.local = create('http://localhost:5984/master/')
} else {
this.local = create('db')
this.remote = create('http://localhost:5984/master/')
}
}
}
const db = new Database()
export default async ({ Vue, ssrContext }) => {
await db.configure({ isSSR: !!ssrContext })
Vue.prototype.$db = db
}
export { db }
5.4 Seeding the database
Now, let's seed our database with some data. We'll do that only at the server-side. And again, we'll do that in a separate file:
In order to generate our data (for this article), we'll use FakerJS
yarn add faker
Create ./src/boot/pouchdb/seed.js
and modify it to look like this:
import uuid from '@toby.mosque/utils'
import faker from 'faker'
export default async function (db) {
var { people: dbpeople } = await db.rel.find('person', { limit: 1 })
if (dbpeople && dbpeople.length > 0) {
return
}
faker.locale = 'en_US'
let companies = []
for (let i = 0; i < 5; i++) {
let company = {}
company.id = uuid.comb()
company.name = faker.company.companyName()
companies.push(company)
}
let jobs = []
for (let i = 0; i < 10; i++) {
let job = {}
job.id = uuid.comb()
job.name = faker.name.jobTitle()
jobs.push(job)
}
let people = []
for (let i = 0; i < 100; i++) {
let companyIndex = Math.floor(Math.random() * Math.floor(5))
let jobIndex = Math.floor(Math.random() * Math.floor(10))
let company = companies[companyIndex]
let job = jobs[jobIndex]
let person = {}
person.id = uuid.comb()
person.firstName = faker.name.firstName()
person.lastName = faker.name.lastName()
person.email = faker.internet.email()
person.company = company.id
person.job = job.id
people.push(person)
}
for (let company of companies) {
await db.rel.save('company', company)
}
for (let job of jobs) {
await db.rel.save('job', job)
}
for (let person of people) {
await db.rel.save('person', person)
}
}
Now call the seed when the boot is running at the server-side:
import create from './create'
import seed from './seed'
class Database {
local = void 0
remote = void 0
syncHandler = void 0
async configure ({ isSSR }) {
if (isSSR) {
this.local = create('http://localhost:5984/master/')
await seed(this.local)
} else {
this.local = create('db')
this.remote = create('http://localhost:5984/master/')
}
}
}
const db = new Database()
export default async ({ Vue, ssrContext }) => {
await db.configure({ isSSR: !!ssrContext })
Vue.prototype.$db = db
}
export { db }
5.5 Sync the database
Finally, we need to sync the data between the remote and the local databases.
When the app starts, before anything, we will try to do a complete replication. To make that task more clear, we'll wrap the replication method inside a promise:
async replicate ({ source, target }) {
return new Promise((resolve, reject) => {
source.replicate.to(target).on('complete', resolve).on('error', reject)
})
}
We'll verify if the app is online and try to do a complete replication (remember, the client has to be online for this action). If something goes wrong, it is because the client is offline or the CouchDB, but that wouldn't prevent the user from accessing the system.
if (navigator.onLine) {
try {
await this.replicate({ source: this.remote, target: this.local })
await this.replicate({ source: this.local, target: this.remote })
} catch (err) {
}
}
After that, we'll start the live replication and track any changes.
this.syncHandler = this.local.sync(this.remote, {
live: true,
retry: true
})
this.local.changes({
since: 'now',
live: true,
include_docs: true
}).on('change', onChange)
Now your boot file would look like this:
import create from './create'
import seed from './seed'
class Database {
local = void 0
remote = void 0
syncHandler = void 0
async configure ({ isSSR, onChange }) {
if (isSSR) {
this.local = create('http://localhost:5984/master/')
await seed(this.local)
} else {
this.local = create('db')
this.remote = create('http://localhost:5984/master/')
if (navigator.onLine) {
try {
await this.replicate({ source: this.remote, target: this.local })
await this.replicate({ source: this.local, target: this.remote })
} catch (err) {
}
}
this.syncHandler = this.local.sync(this.remote, {
live: true,
retry: true
})
this.local.changes({
since: 'now',
live: true,
include_docs: true
}).on('change', onChange)
}
}
async replicate ({ source, target }) {
return new Promise((resolve, reject) => {
source.replicate.to(target).on('complete', resolve).on('error', reject)
})
}
}
const db = new Database()
export default async ({ Vue, ssrContext }) => {
await db.configure({
isSSR: !!ssrContext,
onChange (change) {
console.log(change)
}
})
if (!ssrContext) {
var { people } = await db.rel.find('person')
console.log(people)
}
Vue.prototype.$db = db
}
export { db }
5.6 How your project would look like?
6 CouchDb
6.1 Accessing the CouchDb from the App
If you try to run your app, you'll notice than CouchDB is refusing any connection from the client-side. Here you have two options; configure your app to act as a reverse proxy of the CouchDB, or configure the CORS of your CouchDb instance.
6.1.1 Alternative 1 - Configuring the CORS
Open the Fauxton (http://localhost:5984/_utils), go into the configurations, CORS, and enable it.
6.1.2 Alternative 2 - Reverse Proxy
Install the follow package
yarn add --dev http-proxy-middleware
Edit your ./src-ssr/extention.js
to look like this:
var proxy = require('http-proxy-middleware')
module.exports.extendApp = function ({ app, ssr }) {
app.use(
'/db',
proxy({
target: 'http://localhost:5984',
changeOrigin: true,
pathRewrite: { '^/db': '/' }
})
)
}
Edit your boot file:
if (isSSR) {
this.local = create('http://localhost:5984/master/')
await seed(this.local)
} else {
this.local = create('db')
// you can't use a relative path here
this.remote = create(`${location.protocol}//${location.host}/db/master/`)
}
6.1.3 Silver Bullet
You don't know what alternative to pick? Use the reverse proxy, since that will give to you more freedom.
6.2 Testing the Access
Run your app:
$ quasar dev -m ssr
Now check your console. If you see a list with 100 persons, everything is running as expected.
7 Centralized Data
7.1 Store
Since this is an SSR app, we don't want to query the whole database at the server-side, but would be a good idea to query the domain entities. We'll handle the job and company entities as being our domain entities (since they are used in all routes).
Our first step, is create a store (using Vuex) to hold the both collections:
src/store/database.js
import { factory } from '@toby.mosque/utils'
import { db } from 'src/boot/pouchdb'
const { store } = factory
const options = {
model: class PeopleModel {
companies = []
jobs = []
},
collections: [
{ single: 'company', plural: 'companies', id: 'id' },
{ single: 'job', plural: 'jobs', id: 'id' }
]
}
export default store({
options,
actions: {
async initialize ({ commit }) {
let { companies } = await db.local.rel.find('company')
let { jobs } = await db.local.rel.find('job')
commit('companies', companies)
commit('jobs', jobs)
}
}
})
src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import database from './database'
Vue.use(Vuex)
export default function () {
const Store = new Vuex.Store({
modules: {
database
},
strict: process.env.DEV
})
return Store
}
7.2 Emitting Events
Since our data is being synced with a remote database in real-time, the CRUD operations will happen outside of our store. Because of that, we need to track them and emit events to update our centralized store every time that happens.
In order to do that, we need to modify the boot file: ./src/boot/pouchdb/index.js
// ...
const db = new Database()
export default async ({ Vue, store, router, ssrContext }) => {
await db.configure({
isSSR: !!ssrContext,
onChange (change) {
let { data, _id, _rev, _deleted } = change.doc
let parsed = db.local.rel.parseDocID(_id)
let event = events[parsed.type]
if (_deleted) {
router.app.$emit(parsed.type, { id: parsed.id, _deleted })
router.app.$emit(parsed.id, { _deleted })
if (event) {
store.dispatch(event.delete, parsed.id)
}
} else {
data.id = parsed.id
data.rev = _rev
router.app.$emit(parsed.type, data)
router.app.$emit(parsed.id, data)
if (event) {
store.dispatch(event.save, data)
}
}
}
})
await store.dispatch('database/initialize')
Vue.prototype.$db = db
}
export { db }
7.3 Explanation
let's imagine that someone updated a person, in that case the change object will look like:
{
id: person_2_016d0c65-670c-1d7d-9b96-f3ef340aa681,
seq: ...,
changes: [{ ... }, { ... }],
doc: {
"_id": "person_2_016d0c65-670c-1d7d-9b96-f3ef340aa681",
"_rev": "2-0acd99b71f352cca4c780c90d5c23608",
"data": {
"firstName": "Mylene",
"lastName": "Schmitt",
"email": "Coby83@gmail.com",
"company": "016d0c65-670a-8add-b10f-e9802d05c93a",
"job": "016d0c65-670b-37bf-7d79-b23daf00fe58"
}
}
}
In order to properly index the docs, the relational-pouch plugin modifies the id before of save, appending the type of doc and the type of the key (2 means the key is a string). sWe need break it down in order to get the type of the doc and your id.
let _id = 'person_2_016d0c65-670c-1d7d-9b96-f3ef340aa681'
let parsed = db.local.rel.parseDocID(_id)
console.log(parsed)
// { id: '016d0c65-670c-1d7d-9b96-f3ef340aa681', type: 'person'}
Now, we will emit 2 events to inform the app that some document got updated.
- The first one, is meant to inform components who hold a collection of records, the event name is the type.
- The second one, is meant to inform components who hold the details of a specific record, the event name is the record id (that is unique across the app).
if (_deleted) {
router.app.$emit('person', { id: '016d0c65-670c-1d7d-9b96-f3ef340aa681', _deleted: true })
router.app.$emit('016d0c65-670c-1d7d-9b96-f3ef340aa681', { _deleted: true })
} else {
data.id = parsed.id
data.rev = _rev
router.app.$emit('person', data)
router.app.$emit('016d0c65-670c-1d7d-9b96-f3ef340aa681', data)
}
Our last step, is update the centralized store. We will dispatch an action that will update the store:
if (_deleted) {
if (event) {
store.dispatch('database/deletePerson', parsed.id)
}
} else {
if (event) {
store.dispatch('database/saveOrUpdatePerson', data)
}
}
8 Setting the Framework
Let's configure the framework to use the preFetch and auto discovery the components. Set the config > preFetch
to true
and config > framework > all
to 'auto'
. Here a simplified view of the ./quasar.conf.js
const path = require('path')
module.exports = function (ctx) {
return {
build: {
preFetch: true,
framework: {
all: 'auto',
plugins: [...]
}
}
}
}
9 Listing the People
We already have some data working and the syncing process is configured. Let's create some pages. But first, we need to update the src/router/routes.js
file to look like.:
9.1 Configuring the Route
const routes = [
{
path: '/',
component: () => import('layouts/MyLayout.vue'),
children: [
{ path: '', redirect: '/people/' },
{ path: 'people/', component: () => import('pages/People/Index.vue') },
{ path: 'people/:id', component: () => import('pages/Person/Index.vue') }
]
}
]
// Always leave this as last one
if (process.env.MODE !== 'ssr') {
routes.push({
path: '*',
component: () => import('pages/Error404.vue')
})
}
export default routes
9.2 Creating a View
Now, create the src/pages/People/Index.vue
file to look like this:
<template>
<q-page class="q-pa-md">
<q-table title="People" :data="people" :columns="columns" row-key="id" >
<template v-slot:top-left>
<q-btn color="positive" icon="edit" label="create" to="/people/create" />
</template>
<template v-slot:body-cell-actions="props">
<q-td class="q-gutter-x-sm">
<q-btn round outline color="primary" icon="edit" :to="'/people/' + props.value" />
<q-btn round outline color="negative" icon="delete" @click="remove(props.row)" />
</q-td>
</template>
</q-table>
</q-page>
</template>
<style>
</style>
<script src="./Index.vue.js">
</script>
9.3 Adding a State Container and an Empty Page
We need to create src/pages/People/Index.vue.js
. Out first step will be create a state container
and an empty page:
import { factory } from '@toby.mosque/utils'
import { db } from 'src/boot/pouchdb'
import { mapGetters, mapActions } from 'vuex'
const { page, store } = factory
const moduleName = 'people'
const options = {
model: class PeopleModel {
people = []
},
collections: [
{ single: 'person', plural: 'people', id: 'id' }
]
}
const storeModule = store({
options,
actions: {
async initialize ({ commit }, { route }) {
let { people } = await db.local.rel.find('person')
commit('people', people)
},
async remove (context, person) {
await db.local.rel.del('person', { id: person.id, rev: person.rev })
}
}
})
export default page({
name: 'PeoplePage',
options,
moduleName,
storeModule,
mounted () { ... },
destroyed () { ... },
data () { ... },
computed: { ... },
methods: {
...mapActions(moduleName, { __remove: 'remove' }),
...
}
})
If you're worried that the remove
action didn't commit
anything, that is intentional. Since we'll be listening for changes, as soon a person gets deleted (no matter who, where and/or when), it will be reflected at the state container.
9.4 Listening for Changes
In order to listen for any changes at the people collection, we'll need to update the mounted and destroyed hooks, and enable/disable some events listeners.
export default page({
...
mounted () {
let self = this
if (!this.listener) {
this.listener = entity => {
if (entity._deleted) {
self.deletePerson(entity.id)
} else {
self.saveOrUpdatePerson(entity)
}
}
this.$root.$on('person', this.listener)
}
},
destroyed () {
if (this.listener) {
this.$root.$off('person', this.listener)
}
}
...
})
Doing this, every time when a person gets created, updated or deleted, the state container will be updated, regardless of the origin of the modification.
9.5 Table and Columns
Since we're using a table to display the people, we will need to configure our columns, six in total (firstName
, lastName
, email
, job
, company
, actions
).
But, the job
and company
fields didn't hold the descriptions, but ids, we'll need to map them to your respective descriptions. We'll need to edit the computed
properties to look like:
export default page({
...
computed: {
...mapGetters('database', ['jobById', 'companyById'])
}
...
})
Now, we'll create the columns definitions inside the data
hook
export default page({
...
data () {
let self = this
return {
columns: [
{ name: 'firstName', field: 'firstName', label: 'First Name', sortable: true, required: true, align: 'left' },
{ name: 'lastName', field: 'lastName', label: 'Last Name', sortable: true, required: true, align: 'left' },
{ name: 'email', field: 'email', label: 'Email', sortable: true, required: true, align: 'left' },
{
name: 'job',
label: 'Job',
sortable: true,
required: true,
field (row) { return self.jobById(row.job).name },
align: 'left'
},
{
name: 'company',
label: 'Company',
sortable: true,
required: true,
field (row) { return self.companyById(row.company).name },
align: 'left'
},
{ name: 'actions', field: 'id', label: 'Actions', sortable: false, required: true, align: 'center' }
]
}
},
...
})
9.6 Actions
It's time to configure our actions. To be exact, our unique action: delete a person. We'll edit our methods hook to look like this:
export default page({
...
methods: {
...mapActions(moduleName, { __remove: 'remove' }),
remove (row) {
this.$q.dialog({
color: 'warning',
title: 'Delete',
message: `Do u wanna delete ${row.firstName} ${row.lastName}`,
cancel: true
}).onOk(async () => {
try {
await this.__remove(row)
this.$q.notify({
color: 'positive',
message: 'successfully deleted'
})
} catch (err) {
console.error(err)
this.$q.notify({
color: 'negative',
message: 'failed at delete'
})
}
})
}
}
})
9.7 Screenshots
10 Editing a Person
10.1 Creating a View
Create the src/pages/Person/Index.vue
file, and edit it to look like this:
<template>
<q-page class="q-pa-md">
<q-card class="full-width">
<q-card-section>
Person
</q-card-section>
<q-separator />
<q-card-section class="q-gutter-y-sm">
<q-input v-model="firstName" label="First Name" outlined />
<q-input v-model="lastName" label="Last Name" outlined />
<q-input v-model="email" label="Email" type="email" outlined />
<q-select v-model="company" label="Company" map-options emit-value option-value="id" option-label="name" outlined :options="companies" />
<q-select v-model="job" label="Job" map-options emit-value option-value="id" option-label="name" outlined :options="jobs" />
</q-card-section>
<q-separator />
<q-card-actions class="row q-px-md q-col-gutter-x-sm">
<div class="col col-4">
<q-btn class="full-width" color="grey-6" label="return" to="/people/" />
</div>
<div class="col col-8">
<q-btn class="full-width" color="positive" label="save" @click="save" />
</div>
</q-card-actions>
</q-card>
</q-page>
</template>
<style>
</style>
<script src="./Index.vue.js">
</script>
10.2 Adding a State Container and an Empty Page
We need to create src/pages/Person/Index.vue.js
, our first step will be create a state container
and a empty page:
import { factory, store as storeUtils, uuid } from '@toby.mosque/utils'
import { db } from 'src/boot/pouchdb'
import { mapActions } from 'vuex'
const { mapState } = storeUtils
const { page, store } = factory
const options = {
model: class PersonModel {
id = ''
rev = ''
firstName = ''
lastName = ''
email = ''
job = ''
company = ''
}
}
const moduleName = 'person'
const storeModule = store({
options,
actions: {
async initialize ({ dispatch, commit }, { route }) {
let person = await dispatch('personById', route.params.id)
commit('id', person.id || uuid.comb())
commit('rev', person.rev)
commit('firstName', person.firstName)
commit('lastName', person.lastName)
commit('email', person.email)
commit('job', person.job)
commit('company', person.company)
},
async personById (context, id) {
let { people } = await db.local.rel.find('person', id)
let person = people && people.length > 0 ? people[0] : {}
return person
},
async save ({ state }) {
let current = { ...state }
delete current['@@']
await db.local.rel.save('person', current)
}
}
})
export default page({
name: 'PersonPage',
options,
moduleName,
storeModule,
mounted () { ... },
destroyed () { ... },
computed: { ... },
methods: {
...mapActions(moduleName, { __save: 'save', initialize: 'initialize' }),
...
}
})
Again, don't worry with the save
. The lack of a commit
is intentional, since we'll be listening for changes. As soon as the current person gets modified (no matter who, where and/or when) the page will be notified.
10.3 Listening for Changes
In order to listen for any changes to the current person, we'll need to update the mounted and destroyed hooks, and enable/disable some event listeners.
But unlike what we did before, we'll only notify the application and let the user decide what they want to do.
export default page({
...
mounted () {
if (this.rev && !this.listener) {
this.listener = entity => {
if (entity._deleted) {
// if that person got deleted, the unique option to the user is leave that page.
this.$q.dialog({
parent: this,
color: 'warning',
title: 'Deleted',
message: 'Someone deleted this person'
}).onDismiss(() => {
this.$router.push('/people/')
})
} else {
// if that person got update, the user will be able to keep the changes or discard them.
this.$q.dialog({
parent: this,
color: 'warning',
title: 'Deleted',
cancel: 'No',
ok: 'yes',
message: 'Someone updated this person. do u wanna refresh the fields?'
}).onOk(() => {
this.initialize({ route: this.$route })
}).onCancel(() => {
this.rev = entity.rev
})
}
}
this.$root.$on(this.id, this.listener)
}
},
destroyed () {
if (this.rev && this.listener) {
this.$root.$off(this.id, this.listener)
}
},
...
})
Doing this, every time the current person gets updated or deleted, the user will then be notified, regardless of the origin of the modification.
10.4 Data Sources
Like before, the job
and company
fields didn't hold the descriptions, but ids. But now we need the entire collection of jobs
and companies
in order to fetch the QSelect
options.:
export default page({
...
computed: {
...mapState('database', ['jobs', 'companies'])
},
...
})
10.5 Actions
Now, it's the time to write our save method. We'll edit our methods hook to look like:
export default page({
...
methods: {
...mapActions(moduleName, { __save: 'save', initialize: 'initialize' }),
async save () {
try {
await this.__save()
this.$q.notify({
color: 'positive',
message: 'successfully saved'
})
this.$router.push('/people/')
} catch (err) {
this.$q.notify({
color: 'negative',
message: 'failure at save'
})
}
}
}
})
10.6 Screenshots
11 Wrapping the PouchDB instance with a Worker
Until now, all DB operations are being made in the main thread, that includes queries, updates, deletes, sync, etc.
If you have a large database and you're creating or updating documents often, your UI can suffer from constant blocking, that will result in a poor user experience.
Anyway, I really recommend you move any DB operations to a separate thread. to achieve that you'll need this package:
yarn add worker-pouch
11.1 Web Worker
This is the basic setup. Your first step is to verify if the worker adapter
is configured. Just open the src/boot/pouchdb/setup.js
and look for:
import PouchDB from 'pouchdb'
import WorkerPouch from 'worker-pouch'
PouchDB.adapter('worker', WorkerPouch)
export default PouchDB
Our second step, is to configure the local database to use the worker adapter
. Just open src/boot/pouchdb/input.js
and replace:
async configure ({ isSSR, onChange }) {
if (isSSR) {
// ...
} else {
this.local = create('db')
// ...
}
}
with
async configure ({ isSSR, onChange }) {
if (isSSR) {
// ...
} else {
this.local = create('db', { adapter: 'worker' })
// ...
}
}
Done, for now, all our DB operations are now in a separated worker thread.
11.2 Shared Worker
The biggest problem with the synchronous process is if you had multiple browser tabs opened, they will all access a single instance of the LocalStorage. If you update a document in one of the tabs, the others tabs will not be notified.
If you want all of your tabs notified, you'll need to use a SharedWorker
. In this case, you'll have only one worker for all the tabs.
TODO: waiting https://github.com/GoogleChromeLabs/worker-plugin/pull/42 to be merged.
11.3 Service Worker
Besides the name of this article, until now our app isn't a PWA. Let's change that. Open the ./quasar.conf.js
and set the ssr > pwa
to true
.
const path = require('path')
module.exports = function (ctx) {
return {
ssr: {
pwa: true
}
}
}
Now, the workbox is configured and our app has a Service Worker, but we haven't great control over it, anyway we can change that. Open your ./quasar.conf.js and configure your pwa > workboxPluginMode to be InjectManifest:
const path = require('path')
module.exports = function (ctx) {
return {
pwa: {
workboxPluginMode: 'InjectManifest'
}
}
}
Now, we need to edit the ./src-pwa/custom-service-worker.js
to look like this:
/*
* This file (which will be your service worker)
* is picked up by the build system ONLY if
* quasar.conf > pwa > workboxPluginMode is set to "InjectManifest"
*/
/*eslint-disable*/
workbox.core.setCacheNameDetails({prefix: "pouchdb-offline"})
self.skipWaiting()
self.__precacheManifest = [].concat(self.__precacheManifest || [])
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
"directoryIndex": "/"
})
workbox.routing.registerRoute("/", new workbox.strategies.NetworkFirst(), 'GET')
workbox.routing.registerRoute(/^http/, new workbox.strategies.NetworkFirst(), 'GET')
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim())
})
In order to move the DB operations into the Service Worker
, we need to configure the webpack, so it'll be able to transpile some dependencies.
yarn add --dev serviceworker-webpack-plugin
Edit ./quasar.conf.js
one more time:
const path = require('path')
module.exports = function (ctx) {
return {
build: {
extendWebpack (cfg, { isServer }) {
cfg.resolve.alias['pouchdb-promise'] = path.join(__dirname, '/node_modules/pouchdb-promise/lib/index.js')
cfg.module.rules.push({
enforce: 'pre',
test: /\.(js|vue)$/,
loader: 'eslint-loader',
exclude: /node_modules/,
options: {
formatter: require('eslint').CLIEngine.getFormatter('stylish')
}
})
if (!isServer) {
const worker = new ServiceWorkerWebpackPlugin({
entry: path.join(__dirname, 'src-pwa/pouchdb-service-worker.js'),
filename: 'pouchdb-service-worker.js'
})
cfg.plugins = cfg.plugins || []
cfg.plugins.push(worker)
}
}
}
}
}
Now, create the ./src-pwa/pouchdb-service-worker.js
and edit your content to be like:
/*eslint-disable*/
let registerWorkerPouch = require('worker-pouch/worker')
let PouchDB = require('pouchdb')
PouchDB = PouchDB.default && !PouchDB.plugin ? PouchDB.default : PouchDB
registerWorkerPouch = registerWorkerPouch.default && !registerWorkerPouch.call ? registerWorkerPouch.default : registerWorkerPouch
self.registerWorkerPouch = registerWorkerPouch
self.PouchDB = PouchDB
Finally, modify the ./src-pwa/custom-service-worker.js
in order to import the worker-pouch related scripts and register them:
/*
* This file (which will be your service worker)
* is picked up by the build system ONLY if
* quasar.conf > pwa > workboxPluginMode is set to "InjectManifest"
*/
/*eslint-disable*/
importScripts(`pouchdb-service-worker.js`)
workbox.core.setCacheNameDetails({prefix: "pouchdb-offline"})
self.skipWaiting()
self.__precacheManifest = [].concat(self.__precacheManifest || [])
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
"directoryIndex": "/"
})
workbox.routing.registerRoute("/", new workbox.strategies.NetworkFirst(), 'GET')
workbox.routing.registerRoute(/^http/, new workbox.strategies.NetworkFirst(), 'GET')
registerWorkerPouch(self, PouchDB)
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim())
})
We need to modify our ./src/boot/pouchdb/index.js
so the local pouchdb
instance points to the Service Worker
:
async configure ({ isSSR, onChange }) {
if (isSSR) {
// ...
} else {
if ('serviceWorker' in navigator) {
if (!navigator.serviceWorker.controller) {
await new Promise(resolve => {
navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true })
})
}
this.local = create('db', {
adapter: 'worker',
worker () {
return navigator.serviceWorker
}
})
} else {
this.local = create('db', { adapter: 'worker' })
}
// ...
}
}
If you check your network tab, it should now look like:
11.4 Silver Bullet
You don't know what worker to pick? Use the SharedWorker
, since that didn't have drawbacks over the DedicatedWorker
and the ServiceWorker
will not stay active after the app is closed.
12 Syncing when the App is closed
That is just a Overview
The Service Worker
will stay active only while the app is open. Even if we move the DB operations to run inside the Service Worker
the sync will stop as soon the app is closed.
To let the DB be synced even when the app is closed, we'll need to turn our server in a push-server using the web-push, after that, we need to sign the clients to the push server.
After the push is configured, we can configure a cron job to send a push periodically (like each 30 minutes), and the client will start the sync process every time it receives a notification.
13 Repository
You can check the final project here:
https://gitlab.com/TobyMosque/quasar-couchdb-offline
Top comments (11)
Thank you for this article!
Is there a need for extra replication lines in Database.configure?
Documentation for pouchdb claims that
is equivalent to
Or it doesn't work correct in some cases?
you're right, but that isn't what we're doing here.:
is equivalent to:
In that case, both initial/start replications aren't running in parralel, instead of that, we're running the replication from
server
tolocal
firstly. The reason behide that is, we're assuming, in the case of conflict, the server would win. So when we run the replication fromlocal
toserver
, the chance to appear conflicts will be very small.So, if everything goes well, every conflict will be resolved and everything will be in sync before we start the 2-way live replication, where the conflicts probably will be resolved as soon than appear.
If you think I'm being too cautious, I'm really open to suggestions.
Hmm, I think possibility of conflicts does not depend on the order of replication, if a user tries to update an outdated record, conflict is inevitable. But perhaps how conflicts are resolved does depend on this order, I will need to test my cases to figure this out I think.
TL;DR, I'm avoiding 409 responses.
All depends on your conflict resolution strategy. I usually prioritize the server for two reasons.
The first is that the documents may have been replicated to other devices, so the unique affected device is the current one.
secondly it's cheaper to solve locally, pouchdb will not throw a 409 and force you to send other web request. at this point, you can easily ignore/delete the local document or compare both.
Thank you for a great article!
Database sync is working fine.
db.save is also working in seed.js
but db.rel.get('person') in pouchdb/index.js in boot folder gives following error
Cannot read property 'get' of undefined
I am using latest quasar
rel
related methods belongs to a plug-in (relational pouch
), pls, be sure u installed and configured this plug-in.relational pouch
in installed and configuredI fixed the issue it was due to a typo
db.rel.get('person')
in the screenshot of code of boot file where it should bedb.local.rel.get('person')
know I am getting the issue that in the part 10.3 Listening for Changes as the listener isn't being called and when I
console.log(this.listener)
the listener its undefinedThanks in advance
Incredibly smart and concise article. Thank you so much for this writing and code. I'm currently learning vuex-orm, which is extremely nice api for vuex management (there is excellent Luke Diebold tutorial on YouTube to get into vuex-orm quicly: youtube.com/playlist?list=PLFZAa7E...). I would like to connect vuex store to pouchdb (which is in sync with couchdb) but still use vuex-orm on the client. That way I could use all relational stuff on the client (vuex-orm.github.io/vuex-orm/guide/...). Maybe that way whole codebase could be even smaller and simple to use, because vuex-orm makes all vuex mutations under the hood. What do you think about this route?
I really don't know, since i never used
vuex-orm
, and my first impression aboutvuex-orm
is that it was a little over-architectured for my needs. (as any first impression, that can be very biassed).But if you're already familiarized with them, and you think would be easy to keep the module's state synced with the pouchdb, so go ahead.
Just a heads up:
Is now:
Very nice article!
I will definitely use this knowledge for my life!
Incredible, Tobias a great professional, active and very talented!
Congratulations!