loading...
Cover image for Quasar Framework - a SSR+PWA app with dynamic data.

Quasar Framework - a SSR+PWA app with dynamic data.

tobymosque profile image Tobias Mesquita Updated on ・21 min read

Table Of Contents

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.

Fauxton

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

Update the Quasar CLI

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.

Create a new project

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

Install depedencies

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?

Project Overview

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.

Enable CORS

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': '/' }
    })
  )
}

SSR Setup

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

Quasar App

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.

  1. The first one, is meant to inform components who hold a collection of records, the event name is the type.
  2. 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

Project Overview

App Preview

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

Project Overview
Create
On Update
Confirm Delete
On Delete

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:

Network Tab

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

Discussion

pic
Editor guide
Collapse
yul profile image
Yuri Lopukhov

Thank you for this article!
Is there a need for extra replication lines in Database.configure?
Documentation for pouchdb claims that

localDB.sync(remoteDB);

is equivalent to

localDB.replicate.to(remoteDB);
localDB.replicate.from(remoteDB);

Or it doesn't work correct in some cases?

Collapse
tobymosque profile image
Tobias Mesquita Author

you're right, but that isn't what we're doing here.:

try {
  await this.replicate({ source: this.remote, target: this.local })
  await this.replicate({ source: this.local, target: this.remote })
} catch (err) {

}
this.local.sync(this.remote, {
  live: true,
  retry: true
})

is equivalent to:

let sync = function () {
  this.local.sync(this.remote, {
    live: true,
    retry: true
  })
}
this.remote.replicate.to(this.local).on('complete', function () {
  this.local.replicate.to(this.remote).on('complete', function () {
    sync()
  }).on('error', function (err) {
    sync()  
  })
}).on('error', function (err) {
  sync()
})

In that case, both initial/start replications aren't running in parralel, instead of that, we're running the replication from server to local firstly. The reason behide that is, we're assuming, in the case of conflict, the server would win. So when we run the replication from local to server, 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.

Collapse
yul profile image
Yuri Lopukhov

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.

Thread Thread
tobymosque profile image
Tobias Mesquita Author

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.

Collapse
ni9avenger profile image
Wateen Afzal

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

Collapse
tobymosque profile image
Tobias Mesquita Author

rel related methods belongs to a plug-in (relational pouch), pls, be sure u installed and configured this plug-in.

Collapse
ni9avenger profile image
Wateen Afzal

relational pouch in installed and configured

I fixed the issue it was due to a typo db.rel.get('person') in the screenshot of code of boot file where it should be db.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 undefined

Thanks in advance

Collapse
kosirm profile image
Milan Košir

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?

Collapse
tobymosque profile image
Tobias Mesquita Author

I really don't know, since i never used vuex-orm, and my first impression about vuex-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.

Collapse
jimoquinn profile image
Jim O'Quinn

Just a heads up:

  • quasar ext add "@toby-mosque/utils"

Is now:

  • quasar ext add "@toby .mosque/utils"
Collapse
vitorhugosg profile image
Vitor Hugo Soares Gonçalves

Very nice article!

I will definitely use this knowledge for my life!

Incredible, Tobias a great professional, active and very talented!

Congratulations!