@converse/skeletor
Version:
Modernized Backbone with web components
251 lines (228 loc) • 8.33 kB
JavaScript
/**
* IndexedDB, localStorage and sessionStorage adapter
*/
import * as memoryDriver from 'localforage-driver-memory';
import cloneDeep from 'lodash-es/cloneDeep.js';
import isString from 'lodash-es/isString.js';
import localForage from 'localforage';
import mergebounce from 'mergebounce';
import sessionStorageWrapper from './drivers/sessionStorage.js';
import { extendPrototype as extendPrototypeWithSetItems } from 'localforage-setitems';
import { extendPrototype as extendPrototypeWithGetItems } from '@converse/localforage-getitems/dist/localforage-getitems.es6';
import { guid } from './helpers.js';
const IN_MEMORY = memoryDriver._driver;
localForage.defineDriver(memoryDriver);
extendPrototypeWithSetItems(localForage);
extendPrototypeWithGetItems(localForage);
class Storage {
constructor(id, type, batchedWrites = false) {
if (type === 'local' && !window.localStorage) {
throw new Error('Skeletor.storage: Environment does not support localStorage.');
} else if (type === 'session' && !window.sessionStorage) {
throw new Error('Skeletor.storage: Environment does not support sessionStorage.');
}
if (isString(type)) {
this.storeInitialized = this.initStore(type, batchedWrites);
} else {
this.store = type;
if (batchedWrites) {
this.store.debouncedSetItems = mergebounce((items) => this.store.setItems(items), 50, { 'promise': true });
}
this.storeInitialized = Promise.resolve();
}
this.name = id;
}
/**
* @param {'local'|'session'|'indexed'|'in_memory'} type
* @param {boolean} batchedWrites
*/
async initStore(type, batchedWrites) {
if (type === 'session') {
await localForage.setDriver(sessionStorageWrapper._driver);
} else if (type === 'local') {
await localForage.config({ 'driver': localForage.LOCALSTORAGE });
} else if (type === 'in_memory') {
await localForage.config({ 'driver': IN_MEMORY });
} else if (type !== 'indexed') {
throw new Error('Skeletor.storage: No storage type was specified');
}
this.store = localForage;
if (batchedWrites) {
this.store.debouncedSetItems = mergebounce((items) => this.store.setItems(items), 50, { 'promise': true });
}
}
flush() {
return this.store.debouncedSetItems?.flush();
}
async clear() {
await this.store.removeItem(this.name).catch((e) => console.error(e));
const re = new RegExp(`^${this.name}-`);
const keys = await this.store.keys();
const removed_keys = keys.filter((k) => re.test(k));
await Promise.all(removed_keys.map((k) => this.store.removeItem(k).catch((e) => console.error(e))));
}
sync() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this;
async function localSync(method, model, options) {
let resp, errorMessage, promise, new_attributes;
// We get the collection (and if necessary the model attribute.
// Waiting for storeInitialized will cause another iteration of
// the event loop, after which the collection reference will
// be removed from the model.
const collection = model.collection;
if (['patch', 'update'].includes(method)) {
new_attributes = cloneDeep(model.attributes);
}
await that.storeInitialized;
try {
const original_attributes = model.attributes;
switch (method) {
case 'read':
if (model.id !== undefined) {
resp = await that.find(model);
} else {
resp = await that.findAll();
}
break;
case 'create':
resp = await that.create(model, options);
break;
case 'patch':
case 'update':
if (options.wait) {
// When `wait` is set to true, Skeletor waits until
// confirmation of storage before setting the values on
// the model.
// However, the new attributes needs to be sent, so it
// sets them manually on the model and then removes
// them after calling `sync`.
// Because our `sync` method is asynchronous and we
// wait for `storeInitialized`, the attributes are
// already restored once we get here, so we need to do
// the attributes dance again.
model.attributes = new_attributes;
}
promise = that.update(model);
if (options.wait) {
model.attributes = original_attributes;
}
resp = await promise;
break;
case 'delete':
resp = await that.destroy(model, collection);
break;
}
} catch (error) {
if (error.code === 22 && that.getStorageSize() === 0) {
errorMessage = 'Private browsing is unsupported';
} else {
errorMessage = error.message;
}
}
if (resp) {
if (options && options.success) {
// When storing, we don't pass back the response (which is
// the set attributes returned from localforage because
// Skeletor sets them again on the model and due to the async
// nature of localforage it can cause stale attributes to be
// set on a model after it's been updated in the meantime.
const data = method === 'read' ? resp : null;
options.success(data, options);
}
} else {
errorMessage = errorMessage ? errorMessage : 'Record Not Found';
if (options && options.error) {
options.error(errorMessage);
}
}
}
localSync.__name__ = 'localSync';
return localSync;
}
removeCollectionReference(model, collection) {
if (!collection) {
return;
}
const ids = collection.filter((m) => m.id !== model.id).map((m) => this.getItemName(m.id));
return this.store.setItem(this.name, ids);
}
addCollectionReference(model, collection) {
if (!collection) {
return;
}
const ids = collection.map((m) => this.getItemName(m.id));
const new_id = this.getItemName(model.id);
if (!ids.includes(new_id)) {
ids.push(new_id);
}
return this.store.setItem(this.name, ids);
}
getCollectionReferenceData(model) {
if (!model.collection) {
return {};
}
const ids = model.collection.map((m) => this.getItemName(m.id));
const new_id = this.getItemName(model.id);
if (!ids.includes(new_id)) {
ids.push(new_id);
}
const result = {};
result[this.name] = ids;
return result;
}
async save(model) {
if (this.store.setItems) {
const items = {};
items[this.getItemName(model.id)] = model.toJSON();
Object.assign(items, this.getCollectionReferenceData(model));
return this.store.debouncedSetItems ? this.store.debouncedSetItems(items) : this.store.setItems(items);
} else {
const key = this.getItemName(model.id);
const data = await this.store.setItem(key, model.toJSON());
await this.addCollectionReference(model, model.collection);
return data;
}
}
create(model, options) {
/* Add a model, giving it a (hopefully)-unique GUID, if it doesn't already
* have an id of it's own.
*/
if (!model.id) {
model.id = guid();
model.set(model.idAttribute, model.id, options);
}
return this.save(model);
}
update(model) {
return this.save(model);
}
find(model) {
return this.store.getItem(this.getItemName(model.id));
}
async findAll() {
/* Return the array of all models currently in storage.
*/
const keys = await this.store.getItem(this.name);
if (keys?.length) {
const items = await this.store.getItems(keys);
return Object.values(items);
}
return [];
}
async destroy(model, collection) {
await this.flush();
await this.store.removeItem(this.getItemName(model.id));
await this.removeCollectionReference(model, collection);
return model;
}
getStorageSize() {
return this.store.length;
}
getItemName(id) {
return this.name + '-' + id;
}
}
Storage.sessionStorageInitialized = localForage.defineDriver(sessionStorageWrapper);
Storage.localForage = localForage;
export default Storage;