The Finsemble storage client interfaces with the storage service to provide access to a central location for persistent storage and retrieval of application data.
The storage client delivers one form of cross-origin resource sharing (CORS), specifically cross-origin storage. However, by using our storage client, different data stores can be transparently used:
The Storage Client API supports a key-value schema. Each call to get
and save
must include a key.
All data is keyed off of a username, which is specified by the Authentication Service invoking the
StorageClient.setUser method
during startup. Because the username is always set
automatically, you never need to do it. If authentication is disabled, the underlying Authentication Service sets the
username to defaultUser
.
The high-level storage architecture looks something like this:
Key-value is a simple concept. However, sometimes you need a little more flexibility than key-value provides. To allow further partitioning of data, we allow you to pass in a topic. Topics act like a namespace and help prevent key conflicts. All of Finsemble's core storage uses one of the three topics described below. You can use any topic you want (such as an application or company name).
Topics can be mapped to specific storage adapters. For example, you could have a component that has a MySQL backend and another with a Redis backend. It's up to you.
By default, Finsemble saves to three topics which you can extend via config:
finsemble.workspace
is storage for the workspace and is only accessed when a user initializes workspaces or explicitly
saves them.finsemble.workspace.cache
is temporary storage for high-frequency updates (e.g., window moves, resizes, opens,
closes).Finsemble
is a general topic that contains everything outside of the workspace. User preferences fall under this
topic.In your Finsemble config, you can specify storage adapters in the "servicesConfig.storage"
setting. You can (and
should) define your own topics for storage. The topics you define can be mapped to any adapter by inserting a
corresponding entry under the topicToDataStoreAdapters
property inside config.json. The property name (e.g.,
finsemble.workspace
) is the topic, and the value is the adapter (e.g., IndexedDB
). You can specify the default
adapter using the defaultStorage
setting:
"servicesConfig": {
"storage": {
"defaultStorage": "IndexedDBAdapter",
"topicToDataStoreAdapters" : {
"Finsemble" : "IndexedDBAdapter",
"finsemble.workspace" : "IndexedDBAdapter",
"finsemble.workspace.cache" : "IndexedDBAdapter"
},
"dataStoreAdapters": {
"LocalStorageAdapter": "$applicationRoot/adapters/localStorageAdapter.js",
"IndexedDBAdapter": "$applicationRoot/adapters/indexedDBAdapter.js"
}
}
.....
}
Storage config is processed during the initialization of the Storage service, so all related config settings take
place before any storage clients are initialized. If a topic doesn't have a storage adapter in the config, it defaults
to the "defaultStorage"
value. If no "defaultStorage"
value exists, it then it defaults to localStorage
.
The storage service receives requests from the storage client to save data under a particular topic and key. The storage service receives the request, determines which adapter should handle the request, and passes on the information. When the adapter has completed the operation, the storage service returns a message that everything is done.
If you want to use an adapter that uses a remote database (e.g., MongoDB, Oracle's relational database, and so on), you simply need to build an adapter that converts key-value requests into something that your backend can handle.
In general:
finsemble.servicesConfig.storage
section. Note: The localStorageAdapter and IndexedDB adapter use the BaseStorage adapter to generate keys to store data. The
BaseStorage adapter combines several variables available to it and can inherit the set methods for a couple of those
variables, namely:
setBaseName()
and setUser()
. The compound key generation function is simply:
// return full underlying key (based baseName + userName + topic + key) this.getCombinedKey = function (self, params) { return self.baseName + ":" + self.userName + ":" + params.topic + ":" + params.key; };
We provide 2 storage adapters that you can use in your own implementation, or you can modify to fit your needs.
To illustrate how easy it is to create your own adapter, we're going to create an InMemoryStorageAdapter
. Basically,
the adapter will save data to memory. When you quit the app, nothing will be saved. This has limited practical purpose,
but it neatly illustrates the ideas and code required to build a functional (and useful) storage adapter.
We don't want to break our example project, so we're going to write data to a custom topic. All data saved on that topic
will go to our InMemoryStorageAdapter
.
Create a file called InMemoryStorageAdapter.js in the folder src/adapters.
Here's some sample code to get started:
/*
* The baseStorage model provides several utility functions, such as `getCombinedKey`, which will produce a compound key string (for use with a simple key:value store) incorporating the username, topic, and key. For example: For the default user, Finsemble topic and activeWorkspace key: `Finsemble:defaultUser:finsemble:activeWorkspace.`
.
*/
var BaseStorage = require("@finsemble/finsemble-core").models.baseStorage;
var {
Clients: { Logger },
} = require("@finsemble/finsemble-core");
//Because calls to this storage adapter will likely come from many different windows, we will log successes and failures in the central logger.
Logger.start();
const InMemoryStorageAdapter = function () {
// #region Initializes a new instance of the InMemoryStorageAdapter.
BaseStorage.call(this, arguments);
this.myStorage = {};
/**
* Save method.
* @param {object} params
* @param {string} params.topic A topic under which the data should be stored.
* @param {string} params.key The key whose value is being set.
* @param {any} params.value The value being saved.
* @param {function} cb callback to be invoked upon save completion
*/
this.save = (params, cb) => {
// Implement here
return cb(null, { status: "success" });
};
/**
* Get method.
* @param {object} params
* @param {string} params.topic A topic under which the data should be stored.
* @param {string} params.key The key whose value is being set.
* @param {function} cb callback to be invoked upon completion
*/
this.get = (params, cb) => {
// Implement here
return cb(null, null);
};
/**
* Returns all keys that we're saving data for.
* @param {*} params
* @param {*} cb
*/
this.keys = (params, cb) => {
// Implement here
return cb([]);
};
/**
* Delete method.
* @param {object} params
* @param {string} params.topic A topic under which the data should be stored.
* @param {string} params.key The key whose value is being deleted.
* @param {function} cb callback to be invoked upon completion
*/
this.delete = (params, cb) => {
// Implement here
cb();
};
/**
* This method should be used very, very judiciously. It's essentially a method designed to wipe the database for a particular user.
*/
this.clearCache = (params, cb) => {
// Implement here
cb(null, { status: "success" });
};
/**
* Wipes the storage container.
* @param {function} cb
*/
this.empty = (cb) => {
// Implement here
cb();
};
};
InMemoryStorageAdapter.prototype = new BaseStorage();
new InMemoryStorageAdapter("InMemoryStorageAdapter");
module.exports = InMemoryStorageAdapter;
Note: You only need to implement the
save
, get
, keys
, and delete
functions. The functions clearCache
and
empty
are optional, but can be useful for clearing out data during testing; they should probably be disabled on the
server side in production.
Right now this file is doing nothing. To make it useful, you need to modify some code. First, all requests for the topic
IntraSession
should go to the new adapter. Second, you need to modify where the actual adapter sits (under
dataStoreAdapters
). Lastly, you need to put the file in the build.
To do this, go to config.json and change servicesConfig.storage
as below.
"storage": {
"topicToDataStoreAdapters" : {
"Finsemble" : "InMemoryStorageAdapter",
"finsemble.workspace" : "InMemoryStorageAdapter",
"finsemble.workspace.cache" : "IndexedDBAdapter",
"IntraSession": "InMemoryStorageAdapter"
},
"dataStoreAdapters": {
"LocalStorageAdapter": "$applicationRoot/adapters/localStorageAdapter.js",
"IndexedDBAdapter": "$applicationRoot/adapters/indexedDBAdapter.js",
"InMemoryStorageAdapter": "$applicationRoot/adapters/InMemoryStorageAdapter.js"
}
}
Note: In practice, you should configure your new adapter for use for the
finsemble
and finsemble.workspace
topic. When the user does a formal save operation, the data is copied from the finsemble.workspace.cache
topic to the
finsemble.workspace
topic, copying it to remote storage. For the purposes of this example, the
InMemoryStorageAdapter
will not persist data.
Next, if you are using the built-in Webpack/gulp build system provided by Finsemble, add the following definition to your build/webpack/webpack.adapters.entries.json file:
"InMemoryStorageAdapter": {
"output": "adapters/InMemoryStorageAdapter",
"entry":"./src/adapters/InMemoryStorageAdapter.js"
}
To start, you have a member variable on our class called myStorage
. It's a simple object that does nothing
interesting. To make the adapter have simple functionality, give it the ability to save and retrieve data. Add in the
following code in the appropriate places. Take a second to read through what's going on here before implementing these
two methods in your src/adapters/InMemoryStorageAdapter.js
.
this.save = (params, cb) => {
//Retrieves a key that looks like this:
//applicationUUID:userName:topic:key
const combinedKey = this.getCombinedKey(this, params);
//Assign the value to the key on our storage object.
this.myStorage[combinedKey] = params.value;
return cb(null, { status: "success" });
};
this.get = (params, cb) => {
const combinedKey = this.getCombinedKey(this, params);
const data = this.myStorage[combinedKey];
return cb(null, data);
};
this.keys = (params, cb) => {
const keyPreface = this.getKeyPreface(params);
const allKeys = Object.keys(this.myStorage);
const keys = allKeys.filter((key) => key.startsWith(keyPreface)).map((key) => key.replace(keyPreface, ""));
return cb(keys);
};
this.delete = (params, cb) => {
const combinedKey = this.getCombinedKey(this, params);
delete this.myStorage[combinedKey];
cb();
};
this.clearCache = (params, cb) => {
const userPreface = this.getUserPreface(this);
Object.keys(this.myStorage).forEach((key) => {
if (key.startsWith(userPreface)) {
delete this.myStorage(key);
}
});
cb(null, { status: "success" });
};
this.empty = (cb) => {
this.myStorage = {};
cb();
};
/**
* Get the prefix used to filter keys for particular topics and key prefixes.
*
* @param {object} params
* @param {string} params.topic The topic
* @param {string} params.keyPrefix The key prefix (optional).
* @private
*/
this.getKeyPreface = (params) => {
const keyPrefix = "keyPrefix" in params ? params.keyPrefix : "";
const preface = `${this.getUserPreface()}:${params.topic}:${keyPrefix}`;
return preface;
};
/**
* Get prefix for all the user's stored data.
* @private
*/
this.getUserPreface = () => {
const preface = `${this.baseName}:${this.userName}`;
return preface;
};
Make sure this thing is working. Generate a sample component using the Finsemble CLI. At
your command prompt, use >finsemble-cli add component adapterTest
. Put the following code into the JavaScript file
created in src/components/adapterTest
:
function setData(key, value) {
FSBL.Clients.StorageClient.save({ key: key, value: value, topic: "IntraSession" }, (err, data) => {
console.log("StorageClient.set callback invoked");
if (err) {
console.error("StorageClient.save error for key", key, err);
} else {
console.log("Data saved successfully!");
}
});
}
function getData(key) {
FSBL.Clients.StorageClient.get({ key: key, topic: "IntraSession" }, (err, data) => {
console.log("StorageClient.get callback invoked");
if (err) {
console.error("StorageClient.get error for key", key, err);
} else {
console.log("Data found for", key, "data:", data);
}
});
}
//The component gets Webpacked. If you don't explictily make the function global, you won't be able to interact with it in the console later on.
window.getData = getData;
window.setData = setData;
Then, start Finsemble and spawn the sample adapterTest component. When adapterTest is loaded, click it and type
CTRL+SHIFT+I
. This will open the Chrome developer console so that you can test your adapter. Let's see what it does
with a couple types of data:
setData("Chicken_Names", ["Edna", "Peepy", "Taylor", "Violet", "McKenna", "Harold"]);
setData("Sporks", { seemUseful: true, areUnderUtilized: true });
When you see the callback logged for both sets, try to retrieve the data. First, reload the component by pressing
CTRL+R
(this clears the window
object). This demonstrates that you didn't save this data locally. Once your
component reloads, see what comes back when you try to retrieve the list of chicken names that we saved earlier. Enter
the code below into your console.
getData("Chicken_Names");
Edna and her friends have flown all the way from the storage service back to a component. Without our storage adapter, this wouldn't be possible. While this is a silly example, it tackles the basics.
Advanced information about storing data can be found in the Storage Client documentation.
Read the Router tutorial to better understand how Finsemble sends and receives messages.