@converse/skeletor
Version:
Models and Collections for modern web apps
294 lines (268 loc) • 10.3 kB
text/typescript
/**
* IndexedDB, localStorage and sessionStorage adapter
*/
import * as memoryDriver from 'localforage-driver-memory';
import cloneDeep from 'lodash-es/cloneDeep';
import isString from 'lodash-es/isString';
import localForage from 'localforage';
import mergebounce from 'mergebounce';
import sessionStorageWrapper from './drivers/sessionStorage';
import { extendPrototype as extendPrototypeWithSetItems } from 'localforage-setitems';
import { extendPrototype as extendPrototypeWithGetItems } from '@converse/localforage-getitems/dist/localforage-getitems.es6';
import { guid } from './helpers';
import type { Model } from './model';
import type { Collection } from './collection';
import type { SyncOptions, SyncOperation } from './types';
const IN_MEMORY = memoryDriver._driver;
localForage.defineDriver(memoryDriver);
extendPrototypeWithSetItems(localForage);
extendPrototypeWithGetItems(localForage);
/**
* @public
*/
export interface LocalForageWithExtensions {
setItem(key: string, value: any): Promise<any>;
getItem(key: string): Promise<any>;
removeItem(key: string): Promise<void>;
clear(): Promise<void>;
length(): Promise<number>;
key(keyIndex: number): Promise<string>;
keys(): Promise<string[]>;
setItems?(items: Record<string, any>): Promise<void>;
getItems?(keys: string[]): Promise<Record<string, any>>;
debouncedSetItems?: {
(items: Record<string, any>): Promise<void>;
flush?: () => void;
};
}
/**
* @public
*/
class BrowserStorage {
storeInitialized: Promise<void>;
store: LocalForageWithExtensions;
name: string;
static sessionStorageInitialized: Promise<void>;
static localForage: typeof localForage;
constructor(
id: string,
type: 'local' | 'session' | 'indexed' | 'in_memory' | LocalForageWithExtensions,
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 as 'local' | 'session' | 'indexed' | 'in_memory', batchedWrites);
} else {
this.store = type;
if (batchedWrites) {
this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
'promise': true,
});
}
this.storeInitialized = Promise.resolve();
}
this.name = id;
}
/**
* @param type - The storage type: 'local', 'session', 'indexed', or 'in_memory'
* @param batchedWrites - Whether to enable batched writes
*/
async initStore(type: 'local' | 'session' | 'indexed' | 'in_memory', batchedWrites: boolean): Promise<void> {
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 as LocalForageWithExtensions;
if (batchedWrites) {
this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
'promise': true,
});
}
}
flush(): void {
if (this.store.debouncedSetItems && typeof this.store.debouncedSetItems.flush === 'function') {
this.store.debouncedSetItems.flush();
}
}
async clear(): Promise<void> {
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: SyncOperation, model: Model, options: SyncOptions) {
let resp: any, errorMessage: string | undefined, promise: Promise<any>, new_attributes: any;
// 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: any) {
const storageSize = await that.getStorageSize();
if (error.code === 22 && storageSize === 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: Model, collection: Collection | undefined): Promise<any> | undefined {
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: Model, collection: Collection | undefined): Promise<any> | undefined {
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: Model): Record<string, string[]> {
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: Record<string, string[]> = {};
result[this.name] = ids;
return result;
}
async save(model: Model): Promise<any> {
if (this.store.setItems) {
const items: Record<string, any> = {};
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: Model, options: SyncOptions): Promise<any> {
/* 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: Model): Promise<any> {
return this.save(model);
}
find(model: Model): Promise<any> {
return this.store.getItem(this.getItemName(model.id!));
}
async findAll(): Promise<any[]> {
/* Return the array of all models currently in storage.
*/
const keys = (await this.store.getItem(this.name)) as string[] | null;
if (keys?.length) {
const items = await this.store.getItems!(keys);
return Object.values(items);
}
return [];
}
async destroy(model: Model, collection: Collection | undefined): Promise<Model> {
await this.flush();
await this.store.removeItem(this.getItemName(model.id!));
await this.removeCollectionReference(model, collection);
return model;
}
async getStorageSize(): Promise<number> {
return await this.store.length();
}
getItemName(id: string | number): string {
return this.name + '-' + id;
}
}
BrowserStorage.sessionStorageInitialized = localForage.defineDriver(sessionStorageWrapper);
BrowserStorage.localForage = localForage;
export default BrowserStorage;