database/kvs.js

/**
 * The KVS (Key-Value Store) serves as a convenient, high-level data storing mechanism.
 * The KVS is an abstraction layer on top of the configured [Botpress Database]{@link Database}
 * @public
 * @namespace KVS
 * @example
 * bp.kvs
 * bp.db.kvs // ⚠️ Deprecated, will be removed in Botpress 11
 */

import Promise from 'bluebird'
import _ from 'lodash'

import helpers from './helpers'

/*
  Possible options:
    - betweenGetAndSetCallback: will be called between the get and the set
    and wait for promise to resolve
    - tableName: overrides the KVS table's name
*/
module.exports = (knex, options = {}) => {
  const getSetCallback = options.betweenGetAndSetCallback || (() => Promise.resolve())
  const tableName = options.tableName || 'kvs'

  const upsert = (key, value) => {
    let sql

    const params = {
      tableName,
      keyCol: 'key',
      valueCol: 'value',
      modifiedOnCol: 'modified_on',
      key,
      value: JSON.stringify(value),
      now: helpers(knex).date.now()
    }

    if (helpers(knex).isLite()) {
      sql = `
        INSERT OR REPLACE INTO :tableName: (:keyCol:, :valueCol:, :modifiedOnCol:)
        VALUES (:key, :value, :now)
      `
    } else {
      sql = `
        INSERT INTO :tableName: (:keyCol:, :valueCol:, :modifiedOnCol:)
        VALUES (:key, :value, :now)
        ON CONFLICT (:keyCol:) DO UPDATE
          SET :valueCol: = :value, :modifiedOnCol: = :now
      `
    }

    return knex.raw(sql, params)
  }

  /**
   * Returns the unserialized value stored at a given key
   * @param  {String} key  The unique key where you want to get the value at
   * @param  {String=} [path] If specified, returns the value at the path inside the retrieved object (if stored object was an object).
   * @return {?*} Returns the unserialized object of any type stored at that key or null if it doesn't exist
   * @example
   * // Assuming 'user001' is an Object like `{ profile: { first_name: "Sylvain" } }`
   * const first_name = await bp.kvs.get('user001', 'profile.first_name')
   * const fullUser = await bp.kvs.get('user001')
   *
   * // You can also retrieve array elements
   * const first_subscriber = await bp.kvs.get('subscribers', '[0].name')
   * @memberOf! KVS
   * @async
   */
  const get = (key, path) => {
    return knex(tableName)
      .where({ key })
      .limit(1)
      .then()
      .get(0)
      .then(row => {
        if (!row) {
          return null
        }

        const obj = JSON.parse(row.value)
        if (!path) {
          return obj
        }

        return _.at(obj, [path])[0]
      })
  }

  /**
   * Serializes and stores any value at the specified key, and optionally set a value inside an existing object at `path`.
   * @param  {String} key   The unique key of the value you want to store
   * @param  {*} value The value to store. Note that if you provide an object or array, it will be serialized to JSON automatically.
   * Therefore, you have to make sure that your object is serializable (i.e. it has no circular references)
   * @param  {String=} [path]  The path inside the object to set the value (see example)
   * @example
   * const user = { profile: { name: 'Sylvain' } }
   * await bp.kvs.set('user001', user)
   *
   * // You can later overwrite the `name` property directly
   * await bp.kvs.set('user001', 'Sylvain Perron', 'name')
   * @async
   * @memberof! KVS
   */
  const set = (key, value, path) => {
    if (!path) {
      return upsert(key, value)
    }

    const setValue = obj => {
      if (path) {
        _.set(obj, path, value)
        return obj
      } else {
        return value
      }
    }

    return get(key).then(original => {
      return getSetCallback().then(() => {
        if (!_.isNil(original)) {
          return upsert(key, setValue(Object.assign({}, original)))
        } else {
          return upsert(key, setValue({}))
        }
      })
    })
  }

  const bootstrap = () =>
    helpers(knex).createTableIfNotExists(tableName, table => {
      table.string('key').primary()
      table.text('value')
      table.timestamp('modified_on')
    })

  return { get, set, bootstrap }
}