Configuring PouchDB After Login For A Database-Per-User Architecture In Angular 2.4.1
In my earlier exploration of PouchDB in Angular 2, I created a local PouchDB database instance as part of my application bootstrapping process. This would mean that everyone using the Angular 2 app - in the same browser - would be sharing the same local database. For some use-cases, this is fine; but, I am ultimately trying to build an offline-first, PouchDB application that employs a "database per user" architecture. In that case, I can't create the PouchDB database during bootstrapping, since I don't know who's using the application. Instead, I have to wait until the user authenticates themselves (ie, logs-in). Only then can I create the PouchDB database that is tied to that specific user.
Run this demo in my JavaScript Demos project on GitHub.
In many of my Angular 2 demos, I only have one "data access" service. As such, it makes pratical sense to just create the underlying database in that service rather than go through the rigmarole of providing the database as a class definition and injecting it into the service. In reality, however, in a "real" application, this is exactly what we need to do. There will be many data access services that all share the same datastore; as such, the representation of that datastore should be centralized and shared by (ie, injected into) the various services.
ASIDE: You can also think about RESTful API clients a bit like datastores. If you have several services that all hit the same RESTful API, you should create a centralized API client for your Angular 2 application.
In a "database per user" architecture, it's a little bit more complicated. In that scenario, the logged-in user may change several times throughout the life-cycle of the application. But, even if it doesn't, the initial user still can't login until after the application has been bootstrapped and the datastore has been injected into the various services. As such, it becomes important for the implementation of the datastore to remain fluid throughout the life-cycle of application.
To achieve this, with PouchDB, I've created a PouchDBService that provides the current PouchDB instance via a .getDB() method. This service can then swap the internal PouchDB instance as different users log into and out of the application - always returning the active PouchDB database instance when .getDB() is called.
// The PouchDB library is delivered as a CommonJS module and I am not yet sure how to
// configure my System.js setup to allow for a more simple import statement. This is the
// only thing that I can get to work at this time.
// --
// CAUTION: TypeScript still complains, "Cannot find module 'pouchdb'."
import * as PouchDB from "pouchdb";
export class PouchDBService {
private db: any;
// I initialize the service.
constructor() {
this.db = null;
}
// ---
// PUBLIC METHODS.
// ---
// I teardown any existing PouchDB instance and configure a new one for the given
// user identifier. All subsequent calls to getDB() will return the newly configured
// PouchDB instance.
public configureForUser( userIdentifier: string ) : void {
this.teardown();
this.db = new PouchDB( this.getDatabaseName( userIdentifier ) );
// TODO: Setup replication for remote database (not needed for this demo).
console.warn( "Configured new PouchDB database for,", this.db.name );
}
// I get the active PouchDB instance. Throws an error if no PouchDB instance is
// available (ie, user has not yet been configured with call to .configureForUser()).
public getDB() : any {
if ( ! this.db ) {
throw( new Error( "Database is not available - please configure an instance." ) );
}
return( this.db );
}
// I teardown / deconfigure the existing database instance (if there is one).
// --
// CAUTION: Subsequent calls to .getDB() will fail until a new instance is configured
// with a call to .configureForUser().
public teardown() : void {
if ( ! this.db ) {
return;
}
// TODO: Stop remote replication for existing database (not needed for this demo).
this.db.close();
this.db = null;
}
// ---
// PRIVATE METHODS.
// ---
// I return a normalized database name for the given user identifier.
private getDatabaseName( userIdentifier: string ) : string {
// Database naming restrictions from https://wiki.apache.org/couchdb/HTTP_database_API
// --
// A database must be named with all lowercase letters (a-z), digits (0-9), or
// any of the _$()+-/ characters and must end with a slash in the URL. The name
// has to start with a lowercase letter (a-z)... Uppercase characters are NOT
// ALLOWED in database names.
var dbName = userIdentifier
.toLowerCase()
.replace( /[^a-z0-9_$()+-]/g, "-" )
;
return( "javascript-demos-pouchdb-angular2-" + dbName );
}
}
Notice that the PouchDBService exposes a .configureForUser() method in addition to the .getDB() method. This way, the login workflow of the application can tell the PouchDBService when and for whom to create a new PouchDB instance.
Of course, in order for this to work, the data access services that consume this PouchDBService will need to know to call .getDB() every time they need to access data; as opposed to calling .getDB() once and then caching the return value for the lifetime of the service. This use of .getDB() ensures that every data access request is getting routed to the proper PouchDB instance.
To see this in action, I've created a FriendService that manages a collection of friends. It has three primary methods:
- .addFriend( name )
- .deleteFriend( id )
- .getFriends()
In each of these methods, the FriendService makes an explicit call to PouchDBService.getDB(). This ensures that every data access action performed by the FriendService is going to be hitting the correct PouchDB database - the database that's tied to the currently logged-in user:
// Import the core angular services.
import { Injectable } from "@angular/core";
// Import the application components and services.
import { IPouchDBAllDocsResult } from "./pouchdb.interfaces";
import { IPouchDBGetResult } from "./pouchdb.interfaces";
import { IPouchDBPutResult } from "./pouchdb.interfaces";
import { IPouchDBRemoveResult } from "./pouchdb.interfaces";
import { PouchDBService } from "./pouchdb.service";
export interface IFriend {
id: string;
name: string;
}
interface IPouchDBGetFriendResult extends IPouchDBGetResult {
name: string;
}
@Injectable()
export class FriendService {
private pouchdbService: PouchDBService;
// I initialize the Friend service.
constructor( pouchdbService: PouchDBService ) {
// Rather than constructing a PouchDB instance directly, we're going to use the
// PouchDBService to provide a database instance on the fly. This way, the
// configuration for the PouchDB instance can be changed at any point during the
// application life-cycle. Each database interaction starts with a call to
// this.getDB() to access the "current" database rather than a cached one.
this.pouchdbService = pouchdbService;
}
// ---
// PUBLIC METHODS.
// ---
// I add a new friend with the given name. Returns a promise of the generated id.
public addFriend( name: string ) : Promise<string> {
// NOTE: All friends are given the key-prefix of "friend:". This way, when we go
// to query for friends, we can limit the scope to keys with in this key-space.
var promise = this.getDB()
.put({
_id: ( "friend:" + ( new Date() ).getTime() ),
name: name
})
.then(
( result: IPouchDBPutResult ) : string => {
return( result.id );
}
)
;
return( promise );
}
// I delete the friend with the given id. Returns a promise.
public deleteFriend( id: string ) : Promise<void> {
this.testId( id );
// NOTE: For the "delete" action, we need to perform a series of database calls.
// In reality, these will be "instantaneous". However, philosophically, these are
// asynchronous calls. As such, I am setting the DB to a function-local value in
// order to ensure that both database calls - that compose the one workflow - are
// made on the same database. This eliminates the possibility that the "current
// database" may change in the middle of these chained actions.
var db = this.getDB();
// When we delete a document, we have to provide a document that contains, at the
// least, the "_id" and the "_rev" property. Since the calling context doesn't
// have this, we'll use the .get() method to get the current doc, then use that
// result to delete the winning revision of the document.
var promise = db
.get( id )
.then(
( doc: IPouchDBGetFriendResult ) : any => {
return( db.remove( doc ) );
}
)
.then(
( result: IPouchDBRemoveResult ) : void => {
// Here, I'm just stripping out the result so that the PouchDB
// response isn't returned to the calling context.
return;
}
)
;
return( promise );
}
// I get the collection of friends (in no particular sort order). Returns a promise.
public getFriends() : Promise<IFriend[]> {
var promise = this.getDB()
.allDocs({
include_docs: true,
// In PouchDB, all keys are stored in a single collection. So, in order
// to return just the subset of "Friends" keys, we're going to query for
// all documents that have a "friend:" key prefix. This is known as
// "creative keying" in the CouchDB world.
startkey: "friend:",
endKey: "friend:\uffff"
})
.then(
( result: IPouchDBAllDocsResult ) : IFriend[] => {
// Convert the raw data storage into something more natural for the
// calling context to consume.
var friends = result.rows.map(
( row: any ) : IFriend => {
return({
id: row.doc._id,
name: row.doc.name
});
}
);
return( friends );
}
)
;
return( promise );
}
// I sort the given collection of friends (in place) based on the name property.
public sortFriendsCollection( friends: IFriend[] ) : IFriend[] {
friends.sort(
function( a: IFriend, b: IFriend ) : number {
if ( a.name.toLowerCase() < b.name.toLowerCase() ) {
return( -1 );
} else {
return( 1 );
}
}
);
return( friends );
}
// I test the given id to make sure it is valid for the Friends key-space. Since all
// PouchDB documents are stored in a single collection, we have to ensure that the
// given ID pertains to the subset of documents that represents Friends. If the id is
// valid, I return quietly; otherwise, I throw an error.
public testId( id: string ) : void {
if ( ! id.startsWith( "friend:" ) ) {
throw( new Error( "Invalid Id" ) );
}
}
// ---
// PRIVATE METHODS.
// ---
// I return the currently-configured PouchDB instance.
private getDB() : any {
return( this.pouchdbService.getDB() );
}
}
One thing to notice in this service is that in the .deleteFriend() method, I'm calling .getDB() and then storing the returned database instance in a function-local variable. I'm doing this because the delete operation actually requires two database calls: one to get the persisted document and then one to mark it as deleted. In reality, since I'm working with a local database, this will be instantaneous. However, philosophically, these are asynchronous actions; and, there's a chance that the underlying database instance could change in between the two asynchronous calls. As such, by caching the database instance as a local variable, I can ensure that both calls are targeted at the same database.
CAUTION: When the underlying database is changed, in my implementation, the old database is "closed". As such, if the timing were such that the database instance did change during the delete call, the second call would likely fail. To improve upon this, perhaps I could use setTimeout() to delay the closing of the old database by a few seconds in order to allow for multi-call workflows.
Now, to see all of this in action, I've created a trivial demo that allows you to log into the application as one of two users. For the sake of simplicity, the users are hard-coded as tokens; and, the authentication workflow is handled directly in the root component. The main point of interest is the login() method in which we tell the PouchDBService to configure a new database.
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { FriendService } from "./friend.service";
import { IFriend } from "./friend.service";
import { PouchDBService } from "./pouchdb.service";
interface IAddForm {
name: string;
}
@Component({
moduleId: module.id,
selector: "my-app",
styleUrls: [ "./app.component.css" ],
template:
`
<!-- BEIGN: Logged-out view. -->
<template [ngIf]="( user === null )">
<ul>
<li>
<a (click)="login( 'ben' )">Login as Ben</a>
</li>
<li>
<a (click)="login( 'kim' )">Login as Kim</a>
</li>
</ul>
</template>
<!-- END: Logged-out view. -->
<!-- BEIGN: Logged-in view. -->
<template [ngIf]="( user !== null )">
<p>
<strong>Logged-in as {{ user }}</strong>.
<a (click)="logout()">Logout</a>.
</p>
<ul>
<li *ngFor="let friend of friends">
{{ friend.name }}
—
<a (click)="deleteFriend( friend )">Delete</a>
</li>
</ul>
<div class="form">
<input
type="text"
[value]="addForm.name"
(input)="addForm.name = $event.target.value"
(keydown.Enter)="processAddForm()"
/>
<button type="button" (click)="processAddForm()">Add Friend</button>
</div>
</template>
<!-- END: Logged-in view. -->
`
})
export class AppComponent {
public addForm: IAddForm;
public friends: IFriend[];
public user: string;
private friendService: FriendService;
private pouchdbService: PouchDBService;
// I initialize the component.
constructor(
friendService: FriendService,
pouchdbService: PouchDBService
) {
this.friendService = friendService;
this.pouchdbService = pouchdbService;
this.addForm = {
name: ""
};
// To start out, the Friends collection will be empty; and, it must remain
// empty until the user logs-in because, until then, the PouchDB database has
// not been configured and we won't know where to read data from.
this.friends = [];
this.user = null;
}
// ---
// PUBLIC METHODS.
// ---
// I delete the given friend from the list.
public deleteFriend( friend: IFriend ) : void {
this.friendService
.deleteFriend( friend.id )
.then(
() : void => {
this.loadFriends();
},
( error: Error ) : void => {
console.log( "Error:", error );
}
)
;
}
// I login the user with the given identifier.
public login( userIdentifier: string ) : void {
// Now that a new user is logging in, we want to teardown any existing PouchDB
// database and reconfigure a new PouchDB database for the given user. This way,
// each user gets their own database in our database-per-user model.
// --
// CAUTION: For simplicity, this is in the app-component; but, it should probably
// be encapsulated in some sort of "session" service.
this.pouchdbService.configureForUser( userIdentifier );
this.user = userIdentifier;
// Once the new database is configured (synchronously), load the user's friends.
this.loadFriends();
}
// I log the current user out.
public logout() : void {
// When logging the user out, we want to teardown the currently configured
// PouchDB database. This way, we can ensure that rogue asynchronous actions
// aren't going to accidentally try to interact with the database.
// --
// CAUTION: For simplicity, this is in the app-component; but, it should probably
// be encapsulated in some sort of "session" service.
this.pouchdbService.teardown();
this.user = null;
this.friends = [];
}
// I process the "add" form, creating a new friend with the given name.
public processAddForm() : void {
if ( ! this.addForm.name ) {
return;
}
this.friendService
.addFriend( this.addForm.name )
.then(
( id: string ) : void => {
console.log( "New friend added:", id );
this.loadFriends();
this.addForm.name = "";
},
( error: Error ) : void => {
console.log( "Error:", error );
}
)
;
}
// ---
// PRIVATE METHODS.
// ---
// I load the persisted friends collection into the list.
private loadFriends() : void {
this.friendService
.getFriends()
.then(
( friends: IFriend[] ) : void => {
// NOTE: Since the persistence layer is not returning the data
// in any particular order, we're going to explicitly sort the
// collection by name.
this.friends = this.friendService.sortFriendsCollection( friends );
},
( error: Error ) : void => {
console.log( "Error", error );
}
)
;
}
}
In my previous blog post, my root component implemented the OnInit interface and loaded friend data in the ngOnInit() life-cycle method. In this version of the app, however, we can't load the data right away because we don't know who the user is or what database they'll be using. As such, we have to present a logged-out user interface (UI) initially; then, defer loading data until the user authenticates and the underlying PouchDB database is configured.
If we run this Angular 2 application, and try logging in as each user, we can see that the list of friends is unique to that user. That's because each list of friends is being persisted to a different local PouchDB database:
As you can see, a new PouchDB instance is allocated every time I login as a different user. And, since the FriendService makes sure to call .getDB() at the start of every data access operation, I know that every operation targets the correct "database per user" instance.
The "database per user" approach looks like it's going to make offline data synchronization faster and easier because it's a simplified security model managing smaller, cohesive databases. But, the database per user approach adds complexity from an Angular 2 application architecture standpoint because we can't allocate the database until we know who is logged into the application. However, if we have the data access services request the current database instance before each operation, I think we have a relatively easy way to solve this problem.
Want to use code from this post? Check out the license.
Reader Comments
Hi Ben,
thanks for the great article. Playing with PouchDB + Angular at the moment, so really helpful.
Just wondering: Is there any reason you're using the verbose form here:
```
private pouchdbService: PouchDBService;
constructor( pouchdbService: PouchDBService ) {
this.pouchdbService = pouchdbService;
}
```
AFAIK you could do the same with this shorthand:
```
constructor(private pouchdbService: PouchDBService ) {}
```
Or am I missing something?
@Peter,
Glad you found this interesting. PouchDB is pretty cool stuff - I've been digging into how to use Cloudant as a remote Database-as-a-Service for replication. I'm not quite there yet, but I am getting very close.
As far as the syntax, that's just my personal preference. Generally speaking, I like being more explicit in my code, when I can. Using the short-hand only works if you understand what TypeScript is giving you when it compiles down to JavaScript. So, I just like the explicit assignment, even it's more typing. 100% subjective.
@All,
I did a follow-up post for this in which I actually start replicating the data to a remote CouchDB / Cloudant datbase after user authentication:
www.bennadel.com/blog/3212-syncing-local-pouchdb-data-with-remote-ibm-cloudant-database-in-angular-2-4-1.htm
Using PouchDB's .sync() method, this is delightfully easy :D