dane-brown / pulse

✨ Pulse is an application logic library for reactive Javascript frameworks with support for VueJS, React and React Native. Lightweight, modular and powerful, but most importantly easy to understand.

Home Page:https://pulsejs.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Pulse

Pulse is an application logic library for reactive Javascript frameworks with support for VueJS, React and React Native. Lightweight, modular and powerful, but most importantly easy to understand.

Donate on patreon Follow on twitter Join Discord NPM Package Version NPM Monthly Downloads NPM Weekly Downloads NPM Total Downloads NPM Bundle MIN Size GitHub License GitHub Code Size GitHub Repo Size

Features

  • ⚙️ Modular structure using "collections"
  • ⚡ Cached data & filters with dependency based regeneration
  • ✨ Automatic data normalization
  • 🔒 Model based data validation
  • 🕰️ History tracking with smart undo functions
  • 🔮 Create data relations between collections
  • 🤓 Database style functions
  • 💎 SSOT architecture (single source of truth)
  • 📕 Error logging & snapshot bug reporting
  • 🔧 Wappers for helpers, utilities and service workers
  • 🚧 Task queuing for race condition prevention
  • 📞 Promise based HTTP requests and websocket connections
  • ⏳ Timed interval task handler
  • 🚌 Event bus
  • 💾 Persisted data API for localStorage, sessionStorage & more
  • 🔑 Optional pre-built authentication layer
  • 🍃 Lightweight (only 22KB) with 0 dependencies
  • 🔥 Supports Vue, React and React Native
  • ❤ Well documented (I'm getting there...)

Note: Pulse is still in development, some features are not working yet. In this document they're marked as "coming soon".

If you wish contribute, that is very much welcome! But please reach out first so we don't work on the same thing at the same time, twitter dm @jamiepine or Discord jam#0001

Why Pulse?

After exploring the many options for Javascript state libraries, including the popular VueX and Redux, I felt like I needed a simpler solution. I wanted to get more out of a library than just state management― something that could provide solid structure for the entire application. It needed to be stuctured and simple, but also scalable. This library provides everything needed to get a reactive javascript front-end application working fast, taking care to follow best practices and to employ simple terminology that makes sense even to beginners.

I built this framework reflective of the architecture in which we use at Notify.me, and as a replacement for VueX at Notify also, making sure it is also compatible with React and vanilla environments. The team at Notify love it and I think you will too.

Install

npm i pulse-framework --save

Vanilla Setup

Manually setting up pulse without a framework

import Pulse from 'pulse-framework';

new Pulse.Library({
  collections: {
    channels: {},
    posts: {}
  }
});

Setup with Vue & React

import Pulse from 'pulse-framework';

const pulse = new Pulse.Library({
  collections: {
    channels: {},
    posts: {}
  }
});

Vue.use(pulse); // VUE ONLY

export default pulse; // so you can use the instance anywhere that you import this file, needed for React, optional for Vue.

Pulse Library

The "Library" refers to the Pulse configuration files, this is where you define and configure collections (with data, filters, actions etc), request config, services, utilities and so on.

This is everything currently supported by the Pulse Library and how it fits in the tree. It might not make sense to you now, but use this as reference later on once you've read all the individual sections.

const pulse = new Pulse.Library({
  collections: {
    collectionOne: {},
    collectionTwo: {
      // example
      model: {},
      data: {},
      groups: [],
      persist: [],
      routes: {},
      actions: {},
      filters: {},
      watch: {}
    },
    collectionThree: {}
    //etc..
  },
  request: {
    baseURL: 'https://api.notify.me',
    headers: []
  },
  services: {}, // coming soon
  utils: {}, // coming soon
  jobs: {}

  // base
  model: {},
  data: {},
  groups: [],
  persist: [],
  routes: {},
  actions: {},
  filters: {},
  watch: {}
});

For small applications you can keep this in one or two files like shown above, but a medium to large application building out a file stucture like this might be preferred:

├── library
|   ├── index.js
|   ├── request.js
|   ├── channels
|   |   └── index.js
|   |   └── channel.collection.js
|   |   └── channel.actions.js
|   |   └── channel.filters.js
|   |   └── channel.model.js
|   ├── services
|   |   └── ...
|   ├── utils
|   |   └── ...

Basic Usage

Now you can access collections and the entire Pulse instance

// Vanilla & React
import pulse from '.../pulse';

pulse.collectionOne;
pulse.collectionTwo;

// or in VueJS
this.$collectionOne;
this.$collectionTwo;

NOTE: Going forward the examples will just use collection to represent a given collection, how you access them is dependent on the framework you're using Pulse with.

Collections

Pulse provides "collections" as a way to easily save data. Collections are designed for data following the same stucture or 'model'. So channels, posts, comments, reviews, store items etc. Think of a collection like a database table. Each collection comes with database-like methods to manipulate the collected data.

Once you've defined a collection, you can begin saving data to it.

collection.collect(someData, 'groupName');

"groupName" will be explained shortly

Collecting data works like a pre-built Vuex mutation function or a reducer in Redux, it handles data normalization, history and race condition prevention behind the scenes.

Collected data can be an array of objects each with a primary key, or a single object with a primary key.

Here's an example using a basic posts dataset and the Pulse collect() method.

// single object
const post = {
  id: 234,
  title: 'A post!',
  //etc..
}

collection.collect(post, 'feed')

// array of objects
const posts = [
  { id: 323, ... },
  { id: 243, ... },
  { id: 722, ... }
]

collection.collect(posts , 'feed');

What is data normalization?

Put simply, normalizing data is a way to ensure the data we're working with is consistent, accessible and in the structure we expect it. Normalized data is much easier and faster to work with.

In Pulse's case, collection data is stored internally in an object/keys format. Each piece of data is broken up and ingested individually using the "primary key" as a unique identifier. Arrays of primary keys called indexes are used to preserve ordering and the grouping of data (see Groups). This allows us to build a database-like environment.

Primary Keys

Because we need to normalize data for Pulse collections to work, each piece of data collected must have a primary key, this has to be unique to avoid data being overwritten. If your data has id or _id as a property, we'll use that automatically, but if not then you must define it in a "model":

`primaryKey: 'key'`;

or whatever your dataset's unique identifier is. More on that in the models section later.

Groups

You should assign data a "group" as you collect it, this is required if you want to use collected data in React/Vue components reactively.

Groups are exposed on the collection namespace. (collection.groupName)

collection.collect(somedata, 'groupName');
collection.collect(somedata, ['groupName', 'anotherGroupName']);

Groups create arrays of IDs called indexes, which are arrays of primary keys used to build arrays of actual data. This makes handing data much faster.

The raw indexes are also accessible if you need them.

collection.indexes.groupName;
// returns: [1, 2, 3, 4, 5];

Each time an object's index changes, the related group rebuilds its data from the index. In the above case, groupName would be an array containing the data for primary keys 1-5.

You can modify the index directly and it will trigger the group to regenerate with the correct data.

NOTE: You must define groups in the collection library if you want them to be exposed publicly to your components, filters and actions:

collection: {
  groups: ['groupName', 'anotherGroupName'],
}

If necessary, groups can be created dynamically, however they will not be exposed publicly like regular groups. You can still make use of them by calling collection.getGroup('name'). This method can be used throughout the Pulse library, and is reactive within filters. More information on the getGroup() method, and ones similar later on.

Using Data

Using data in VueJS and React is simple with mapData(). It will return an object containing Pulse data properties that you request. The string must contain a slash, first the name of the collection, then the data property.

pulse.mapData({
  localName: 'collection/property'
});
// returns: { localName: value }

You can set localName to anything that suits your component.

You can now bind each returned property to the data in your component using object spreading. In VueJS the mapData() funtion is available on the Vue instance as this.mapData().

// VueJS
data() {
  return {
    ...this.mapData({
      customName: 'collection/property',
    })
    // etc..
    localDataHere: true,
  },
}

// React
import pulse from '../pulseLib.js'

class extends Component {
  constructor(props) {
    super(props)
    this.state = {
      ...pulse.mapData({
        customName: 'collection/property',
      }, this)
      // etc..
      localDataHere: true,
    }
  }

}

mapData() has access to all public facing data, filters, groups, indexes and even actions. Using mapData enures this component is tracked as a dependency inside Pulse so that it can be reactive.

Now you can access customName in the component instance.

Note: mapData() is read-only. To mutate data or call actions, you must use the Pulse instance itself. A good way is to export the Pulse instance and import it into your component as shown earlier.

For convenience with Vue, each collection is fully accessible on the component (non-reactively) under $ namespace: this.$collection.somethingToChange = true.

In Vue, mapped data can be used in computed methods and even trigger Vue watchers, just like regular Vue data.

In React, data should be mapped to state, and it is compatible with React hooks.

Base Collection

By default the root of the Pulse library is a collection called "base". It's the same as any other collection, but with some extra data properties and logic built in out of the box.

Default Properties

The base and request collections are created by default, with their own custom data properties and related logic. Use of these is optional, but can save you time!

Property type default Description
base.isAuthenticated Boolean false Can be set manually, but will automatically set true if a Set-Cookie header is present on a request response. And automatically set false if a 401 error is returned on a request.
base.appReady Boolean false Once Pulse has completed initialization, this will be set to true.
request.baseURL String null The base URL for making HTTP requests.
request.headers Object (See Request) Headers for outgoing HTTP requests.

More will be added soon!

Persisting Data

To persist data use an array on your collection with the names of data properties you want to save locally.

collection: {
  data: {
    haha: true;
  }
  persist: ['haha'];
}

Pulse intergrates directly with local storage and session storage, and even has an API to configure your own storage.

{
  collections: {...}
  // use session storage
  storage: 'sessionStorage'
  // use custom storage
  storage: {
    set: ...
    get: ...
    remove: ...
    clear: ...
  }
}

Local storage is the default and you don't need to define a storage object for it to work.

More features will be added to data persisting soon, such as persisting entire collection data, custom storage per collection and more configuration options.

Collection Namespace

Pulse has the following namespaces for each collection

  • Groups (cached data based on arrays of primary keys)
  • Data (custom data, good for stuff related to a collection, but not part the main body of data like booleans and strings)
  • Filters (like VueX getters, these are cached data based on filter functions you define)
  • Actions (functions to do stuff)

By default, you can access everything under the collection namespace, like this:

collection.groupName; // array
collection.randomDataName; // boolean
collection.filterName; // cached array
collection.doSomething(); // function

But if you prefer to seperate everything by type, you can access areas of your collection like so:

collection.groups.groupName; //array
collection.data.randomDataName; // boolean
collection.filters.filterName; // cached array
collection.actions.doSomething(); // function

For groups, if you'd like to access the raw array of primary keys, instead of the constructed data you can under indexes.

collection.indexes.groupName; // EG: [ 123, 1435, 34634 ]

Mutating Data

Changing data in Pulse is easy, you just set it to a new value.

collection.currentlyEditingChannel = true;

We don't need mutation functions like VueX's "commit" because we use Proxies to intercept changes and queue them to prevent race condidtions. Those changes are stored and can be reverted easily. (Intercepting and queueing coming soon)

Actions

Actions are simply functions within your pulse collections that can be called externally.

Actions receive a context object (see Context Object) as the first paramater, this includes every registered collection by name, the routes object and all default collection functions.

actionName({ collectionOne, collectionTwo }, customParam, ...etc) {
  // do something
  collectionOne.collect
  collectionTwo.anotherAction()
  collectionTwo.someOtherData = true
};

Collection Functions

These are default functions attached to every collection. They can be called within your actions in the Pulse Library, or directly on your component.

// put data by id (or array of IDs) into another group
collection.put(2123, 'selected');

// move data by id (or array of IDs) into another group
collection.move([34, 3], 'favorites', 'muted');

// change single or multiple properties in your data
collection.update(2123, {
  avatar: 'url'
});

// replace data (same as adding new data)
collection.collect(res.data.channel, 'selected');

// removes data via primary key from a collection
collection.delete(1234);

// will delete all data and empty all groups for a given collection
collection.purge();

// (coming soon) removes any data from a collection that is not currently refrenced in a group
collection.clean();

// (coming soon) will undo the action its called within, or the last action executed if called from outside
collection.undo();

It's recommended to use these functions within Pulse actions. For example, collection.undo() called within an action, will undo everything changed within that action, here's an example: (although undo is still not finished but this is how it will work)

actions: {
  doSeveralThings({ routes, collectionOne, undo }, customParam) {

    collectionOne.someValue = 'hi'

    routes.someRoute(customParam).then(res => {

      collectionOne.collect(res.data, 'groupOne')
      collectionOne.someOtherValue = true

    }).catch((error) => undo())
  }
}

If the catch block is triggered, the undo method will revert all changes made in this action, setting customValue back to its previous value, removing collected data and any changes to groupOne and reverting someOtherValue also. If the group was created during this action, it will be deleted.

Filters

Filters allow you to alter data before passing it to your component without changing the original data. Essentially getters in VueX.

They're cached for performance, meaning the output of the filter function is what gets served to the component, so each time it is accessed the entire filter doesn't need to re-run.

Each filter is analyzed to see which data properties they depend on, and when those data properties change the appropriate filters regenerate.

channels: {
  groups: ['subscribed'],
  filters: {
    liveChannels({ groups }) => {
      return groups.subscribed.filter(channel => channel.live === true)
    }
  }
}

Filters have access to the context object (see Context Object) as the first paramater.

Filters can also be dependent on each other via the context object.

Context Object

Filters and actions receive the "context" object the first paramater.

Name Type Description Filters Actions
Collection Objects Object(s) For each collection within pulse, this is its public data and functions. True True
routes Object The local routes for the current collection. False True
actions Object The local actions for the current collection. True True
filters Object The local filters for the current collection. True True
groups Object The local groups for the current collection. True True
findById Function A helper function to return data directly by primary key. True True
collect Function The collect function, to save data to this collection. False True
put Function Insert data into a group by primary key. False True
move Function Move data from one group to another. False True
update Function Mutate properties of a data entry by primary key. False True
delete Function Delete data. False True
deleteGroup Function Delete data in a group False True
clear Function Remove unused data. False True
undo Function Revert all changes made by this action. False True
throttle Function Used to prevent an action from running more than once in a specified time frame. EG: throttle(2000) for 2s False True
purge Function Clears all collection data and empties groups. False True

HTTP Requests & Routes

Pulse completely replaces the need to use a third party HTTP request library such as Axios. Define endpoints within your collection and easily handle the response and collect the data.

First you must define your baseURL in the request config (in the root of your Pulse library):

  request: {
    baseURL: 'https://api.notify.me'
    headers: {
      'Access-Control-Allow-Origin': 'https://notify.me'
      //etc..
    }
  }
  // for context ...
  collections: {}
  storage: {}
  //etc..

Now you can define routes in your collections:

routes: {
  getStuff: request => request.get('stuff/something');
}

Each route takes in the request object as the first paramater, which contains HTTP methods like, GET, POST, PATCH, DELETE etc.

Route functions are promises, meaning you can either use then() or async/await.

You can access routes externally or within Pulse actions.

collection.routes.getStuff();
actions: {
  doSomething({collection, routes}) {
    return routes.getStuff().then(res => {
      collection.collect(res.data)
    })
  }
}

The request library is an extention of a collection, meaning it's built on top of the collection class. It's exposed on the instance the same way as a collection, data such as baseURL and the headers can be changed on the fly.

request.baseURL = 'https://api.notify.gg';

request.headers['Origin'] = 'https://notify.me';

Request history is saved (collected) into the request collection by default, though this can be disabled:

request: {
  saveHistory: false;
}

HTTP requests will eventually have many more useful features, but for now basic function is implemented.

Models

Collections allow you to define models for the data that you collect. This is great for ensuring valid data is always passed to your components. It also allows you to define data relations between collections, as shown in the next section.

Here's an example of a model:

collection: {
  model: {
    id: {
      // id is the default primary key, but you can set another
      // property to a primary key if your data is different.
      primaryKey: true;
      type: Number; // coming soon
      required: true; // coming soon
    }
  }
}

Data that does not fit the model requirements you define will not be collected, it will instead be saved in the Errors object as a "Data Rejection", so you can easily debug.

Data Relations

Creating data relations between collections is easy and extremely useful.

But why would you need to create data relations? The simple answer is keeping to our rule that data should not be repeated, but when it is needed in multiple places we should make it dependent on a single copy of that data, which when changed, causes any dependecies using that data to regenerate.

Let's say you have a channel and a several posts which have been made by that channel. In the post object you have an owner property, which is a channel id (the primary key). We can establish a relation between that owner id and the primary key in the channel collection. Now when groups or filters are generated for the posts collection, each piece of data will include the full channel object.

When that channel is modified, any groups containing a post dependent on that channel will regenerate, and filters dependent on those groups will regenerate also.

Here's a full example using the names I referenced above.

collections: {
  posts: {
    model: {
      owner: {
        parent: 'channels', // name of the sister collection
        assignTo: 'channel;' // the local propery to assign the channel data to
      }
    }
  },
  channels: {} // etc..
}

That's it! It just works.

A situation where this proved extremely satisfying, was updating a channel avatar on the Notify app, every instance of that data changed reactively. Here's a gif of that in action.

Gif showing reactivity using Pulse relations

Services

Pulse provides a really handy container for services... (finish this)

Event Bus

(coming soon)

Errors

(implemented but description coming soon)

Data Rejections

(implemented but description coming soon)

Sockets

(coming soon)

Jobs

(coming soon)

Similar to cron jobs, provides an API for setting up interval based tasks for your application, ensures the interval is registered and unregistered correctly and is unique.

Extra information

Use case: groups

To better help you understand how groups could be useful to you, here's an example of how Notify.me uses groups.

Lets take accounts on Notify. Accounts can "favorite" and "mute" channels, on our API we store an array of channel ids that the user has muted, they're called "indexes".

account: {
  id: 235624,
  email: 'hello@jamiepine.com',
  username: 'Jamie Pine',
  muted: [12643, 34666, 34575],
  favorites: [34634, 23535]
}

When our API returns the subscriptions data, we will use the muted and favorites indexes on the account object to build groups of real data that our components can use. Obviously this data must already be collected in order to be included.

// Accounts collection
accounts: {
  groups: ['authed'],
  actions: {
    // after login, we get the user's account
    refresh({ routes, collect, channels }) {
      routes.refresh().then(res => {
        collect(res.account, 'authed')
        // populate the indexes on the post collection
        channels.put(res.account.muted, 'muted')
        channels.put(res.account.favorites, 'favorites')
      })
    }
  }
}
// Channels collection
channels: {
  groups: ['subscriptions', 'favorites', 'muted'],
  actions: {
    // get the subscriptions from the API
    loadSubscriptions({ routes, collect }) {
      routes.getSubscriptions().then(res => {
        collect(res.subsciptions, 'subscriptions')
      })
    }
  }
}

When we finally call loadSubscriptions() the groups favorites and muted will already be populated with primary keys, so when the data is collected, these groups will regenerate with fully built data ready for the component.

Now it's as easy as accessing channels.favorites from within Vue to render an array of favorite channels. Or we could write filters within Pulse using the favorites group.

To add a favorite channel, the action could look like this:

channels: {
  actions: {
    favorite({ channels, routes, undo }, channelId) {
      // update local data first
      channels.put(channelId, 'favorites')
      // make change on API in background
      routes.favoriteChannel(channelId).catch(() => undo())
    }
  }
}

If the API failed to make that change, undo() will revert every change made in this action.

About

✨ Pulse is an application logic library for reactive Javascript frameworks with support for VueJS, React and React Native. Lightweight, modular and powerful, but most importantly easy to understand.

https://pulsejs.org

License:MIT License


Languages

Language:JavaScript 100.0%