DEV Community

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

Posted on • Edited on

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

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
Enter fullscreen mode Exit fullscreen mode

Update the Quasar CLI

We can now create a new project, run the following command:

$ quasar create quasar-offline
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
  ]
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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')
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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)
  })
}
Enter fullscreen mode Exit fullscreen mode

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) {

  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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': '/' }
    })
  )
}
Enter fullscreen mode Exit fullscreen mode

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/`)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) 
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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: [...]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' }),
    ...
  }
})
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
  ...
})
Enter fullscreen mode Exit fullscreen mode

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'])
  }
  ...
})
Enter fullscreen mode Exit fullscreen mode

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' }
      ]
    }
  },
  ...
})
Enter fullscreen mode Exit fullscreen mode

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'
          })
        }
      })
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' }),
    ...
  }
})
Enter fullscreen mode Exit fullscreen mode

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)
    }
  },
  ...
})
Enter fullscreen mode Exit fullscreen mode

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'])
  },
  ...
})
Enter fullscreen mode Exit fullscreen mode

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'
        })
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

with

async configure ({ isSSR, onChange }) {
  if (isSSR) {
    // ...
  } else {
    this.local = create('db', { adapter: 'worker' })
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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())
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
})
Enter fullscreen mode Exit fullscreen mode

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' })
    }
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (11)

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

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

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 • Edited

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

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 • Edited

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

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!