@paperbits/common
Version:
Paperbits common components.
582 lines (443 loc) • 22.4 kB
text/typescript
import * as Objects from "../objects";
import { IObjectStorage, Query, Operator, OrderDirection, Page } from "../persistence";
import { IObjectStorageMiddleware } from "./IObjectStorageMiddleware";
import { EventManager, Events } from "../events";
import { ILocalCache, CachingStrategy } from "../caching";
interface HistoryRecord {
do: () => Promise<void>;
undo: () => Promise<void>;
stateDescription?: string;
}
const changesObjectCacheKey: string = "changesObject";
const stateObjectCacheKey: string = "stateObject";
export class OfflineObjectStorage implements IObjectStorage {
private remoteObjectStorage: IObjectStorage;
private readonly past: HistoryRecord[];
private readonly future: HistoryRecord[];
private readonly middlewares: IObjectStorageMiddleware[];
public cachingStrategy: CachingStrategy;
public autosave: boolean;
constructor(
private readonly stateCache: ILocalCache,
private readonly changesCache: ILocalCache,
private readonly eventManager?: EventManager,
) {
this.remoteObjectStorage = null;
this.cachingStrategy = CachingStrategy.NetworkFirst;
this.autosave = false;
this.middlewares = [];
this.past = [];
this.future = [];
if (eventManager) {
this.eventManager.addEventListener(Events.Undo, () => this.undo());
this.eventManager.addEventListener(Events.Redo, () => this.redo());
}
}
public canUndo(): boolean {
return this.past.length > 0;
}
public canRedo(): boolean {
return this.future.length > 0;
}
public setRemoteObjectStorage(underlyingStorage: IObjectStorage): void {
this.remoteObjectStorage = underlyingStorage;
}
public registerMiddleware(middleware: IObjectStorageMiddleware): void {
this.middlewares.push(middleware);
}
public async addObject(path: string, dataObject: Object, changeDescription: string): Promise<void> {
if (!path) {
throw new Error("Could not add object: Key is undefined.");
}
const dataObjectClone = Objects.clone(dataObject); // To drop any object references
let compensationOfState;
let compensationOfChanges;
const doCommand = async (): Promise<void> => {
const stateObject = await this.stateCache.getItem<object>(stateObjectCacheKey) || {};
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
/* Writing state */
compensationOfState = Objects.setValueWithCompensation(path, stateObject, dataObjectClone);
/* Writng changes */
compensationOfChanges = Objects.setValueWithCompensation(path, changesObject, dataObjectClone);
Objects.cleanupObject(stateObject, { removeNulls: true });
Objects.cleanupObject(changesObject);
await this.stateCache.setItem(stateObjectCacheKey, stateObject);
await this.changesCache.setItem(changesObjectCacheKey, changesObject);
};
const undoCommand = async (): Promise<void> => {
const stateObject = await this.stateCache.getItem<object>(stateObjectCacheKey);
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
/* Undoing state */
Objects.setValueWithCompensation(path, stateObject, compensationOfState);
/* Undoing changes */
Objects.setValueWithCompensation(path, changesObject, compensationOfChanges);
Objects.cleanupObject(stateObject, { removeNulls: true });
Objects.cleanupObject(changesObject);
await this.changesCache.setItem(changesObjectCacheKey, changesObject);
await this.stateCache.setItem(stateObjectCacheKey, stateObject);
};
await this.do(doCommand, undoCommand, changeDescription);
}
public patchObject<T>(path: string, dataObject: T): Promise<void> {
throw new Error("Not implemented");
}
public async updateObject<T>(path: string, dataObject: T, changeDescription: string): Promise<void> {
if (!path) {
throw new Error(`Parameter "path" not specified.`);
}
if (dataObject === undefined) { // Note: "null" is acceptable, which means "delete".
throw new Error(`Parameter "dataObject" not specified.`);
}
const dataObjectClone1 = Objects.clone(dataObject); // To drop any object references
const dataObjectClone2 = Objects.clone(dataObject); // To drop any object references
let compensationOfState;
let compensationOfChanges;
const doCommand = async (): Promise<void> => {
const stateObject = await this.stateCache.getItem<object>(stateObjectCacheKey) || {};
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
/* Writing state */
compensationOfState = Objects.setValueWithCompensation(path, stateObject, dataObjectClone1);
/* Writng changes */
compensationOfChanges = Objects.setValueWithCompensation(path, changesObject, dataObjectClone2);
Objects.cleanupObject(stateObject, { removeNulls: true });
Objects.cleanupObject(changesObject);
await this.changesCache.setItem(changesObjectCacheKey, changesObject);
await this.stateCache.setItem(stateObjectCacheKey, stateObject);
};
const undoCommand = async (): Promise<void> => {
const stateObject = await this.stateCache.getItem<object>(stateObjectCacheKey);
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
/* Undoing state */
Objects.setValueWithCompensation(path, stateObject, compensationOfState);
/* Undoing changes */
Objects.setValueWithCompensation(path, changesObject, compensationOfChanges);
Objects.cleanupObject(stateObject, { removeNulls: true });
Objects.cleanupObject(changesObject);
await this.changesCache.setItem(changesObjectCacheKey, changesObject);
await this.stateCache.setItem(stateObjectCacheKey, stateObject);
};
await this.do(doCommand, undoCommand, changeDescription);
}
public async getObject<T>(path: string): Promise<T> {
if (!path) {
throw new Error(`Parameter "path" not specified.`);
}
const stateCacheKey = `${stateObjectCacheKey}/${path}`;
const stateObject = await this.stateCache.getItem<object>(stateCacheKey);
const changesCacheKey = `${changesObjectCacheKey}/${path}`;
const changesObject = await this.changesCache.getItem<object>(changesCacheKey);
/* 1. Check if there is an object in CHANGES */
// If there is one, return it right away from STATE (which might be slightly different from CHANGES).
if (changesObject !== null && changesObject !== undefined) {
return Objects.clone(stateObject); // Cloning to loose references.
}
/* 2. Check if object marked as deleted in CHANGES. */
if (changesObject === null) {
/*
Note: "null" (not undefined) in changesObject specifically means that this object
has been deleted locally (but not yet saved to underlying storage). Hence, no need to check
neither local nor underlying storage.
*/
return undefined;
}
/* 3. Check if object cached locally (STATE). If yes, return it (without querying REMOTE). */
if (stateObject) {
return Objects.clone(stateObject); // Cloning to loose references.
}
/* 4. If no locally changed (CHANGES) or cached (STATE) object, try querying REMOTE. If found, cache it locally. */
const remoteObjectStorageResult = await this.remoteObjectStorage.getObject<T>(path);
if (remoteObjectStorageResult !== undefined) {
const fetchedObject = Objects.clone(remoteObjectStorageResult); // Cloning to loose references.
await this.stateCache.setItem(stateCacheKey, fetchedObject); // Adding to local cache (STATE).
}
return remoteObjectStorageResult;
}
public async deleteObject(path: string, changeDescription: string): Promise<void> {
if (!path) {
throw new Error(`Parameter "path" not specified.`);
}
let compensationOfState;
let compensationOfChanges;
const doCommand = async (): Promise<void> => {
const stateObject = await this.stateCache.getItem<object>(stateObjectCacheKey) || {};
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
/* Writing state */
compensationOfState = Objects.setValueWithCompensation(path, stateObject, null);
/* Writng changes */
compensationOfChanges = Objects.setValueWithCompensation(path, changesObject, null);
Objects.cleanupObject(stateObject, { removeNulls: true });
Objects.cleanupObject(changesObject);
await this.changesCache.setItem(changesObjectCacheKey, changesObject);
await this.stateCache.setItem(stateObjectCacheKey, stateObject);
};
const undoCommand = async (): Promise<void> => {
const stateObject = await this.stateCache.getItem<object>(stateObjectCacheKey) || {};
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
/* Undoing state */
Objects.setValueWithCompensation(path, stateObject, compensationOfState);
/* Undoinf changes */
Objects.setValueWithCompensation(path, changesObject, compensationOfChanges);
Objects.cleanupObject(stateObject, { removeNulls: true });
Objects.cleanupObject(changesObject);
await this.changesCache.setItem(changesObjectCacheKey, changesObject);
await this.stateCache.setItem(stateObjectCacheKey, stateObject);
};
await this.do(doCommand, undoCommand, changeDescription);
}
private async do(doCommand: () => Promise<void>, undoCommand: () => Promise<void>, changeDescription: string): Promise<void> {
const record: HistoryRecord = { do: doCommand, undo: undoCommand, stateDescription: changeDescription };
await record.do();
this.past.push(record);
if (this.past.length > 10) {
this.past.shift();
}
if (this.autosave) {
this.saveChanges();
}
if (this.eventManager) {
this.eventManager.dispatchEvent("onDataChange");
}
}
public async undo(): Promise<void> {
if (!this.canUndo()) {
return;
}
const record = this.past.pop();
await record.undo();
this.future.push(record);
if (this.autosave) {
this.saveChanges();
}
if (this.eventManager) {
this.eventManager.dispatchEvent("onDataPush");
this.eventManager.dispatchEvent("onDataChange");
}
}
public async redo(): Promise<void> {
if (!this.canRedo()) {
return;
}
const record = this.future.pop();
await record.do();
this.past.push(record);
if (this.eventManager) {
this.eventManager.dispatchEvent("onDataPush");
this.eventManager.dispatchEvent("onDataChange");
}
}
private async searchLocalState<T>(path: string, query?: Query<T>): Promise<Page<T>> {
const stateCacheKey = `${stateObjectCacheKey}/${path}`;
const stateObject = await this.stateCache.getItem<object>(stateCacheKey);
if (!stateObject) {
return {
value: [],
takeNext: null
};
}
const searchObj = Objects.clone(stateObject);
let collection = Object.values(searchObj).filter(x => !!x); // skip deleted objects
if (query) {
if (query.filters.length > 0) {
collection = collection.filter(x => {
let meetsCriteria = true;
for (const filter of query.filters) {
let left = Objects.getObjectAt<any>(filter.left, x);
let right = filter.right;
if (!!right && left === undefined) {
meetsCriteria = false;
continue;
}
if (typeof filter.right === "boolean") {
if (filter.operator !== Operator.equals) {
console.warn("Boolean query operator can be only equals");
meetsCriteria = false;
return;
}
if (((left === undefined || left === false) && filter.right === true) ||
((filter.right === undefined || filter.right === false) && left === true)) {
meetsCriteria = false;
}
continue;
}
if (!left) {
meetsCriteria = false;
continue;
}
const operator = filter.operator;
if (typeof left === "string") {
left = left.toUpperCase();
}
if (typeof right === "string") {
right = right.toUpperCase();
}
switch (operator) {
case Operator.notEmpty:
if (typeof left === "string") {
meetsCriteria = !left;
break;
}
if (Array.isArray(left)) {
meetsCriteria = left.length > 0;
}
break;
case Operator.contains:
if (Array.isArray(left) && Array.isArray(right)) {
meetsCriteria = right.filter(value => left.includes(value))?.length > 0;
break;
}
if (left && !left.includes(right)) {
meetsCriteria = false;
}
break;
case Operator.equals:
if (left !== right) {
meetsCriteria = false;
}
break;
default:
throw new Error("Cannot translate operator into Firebase Realtime Database query.");
}
}
return meetsCriteria;
});
}
if (query.orderingBy) {
const property = query.orderingBy;
collection = collection.sort((x, y) => {
const a = Objects.getObjectAt<any>(property, x);
const b = Objects.getObjectAt<any>(property, y);
const modifier = query.orderDirection === OrderDirection.accending ? 1 : -1;
if (a > b) {
return modifier;
}
if (a < b) {
return -modifier;
}
return 0;
});
}
}
return {
value: collection,
takeNext: null
};
}
private async searchRemote<T>(path: string, query?: Query<T>): Promise<any> {
const remoteQuery = query.copy();
const pageOfRemoteSearchResults = await this.remoteObjectStorage.searchObjects<T>(path, remoteQuery);
const remoteSearchResults = pageOfRemoteSearchResults.value;
/* Remove locally deleted objects from remote search results */
const changesCacheKey = `${changesObjectCacheKey}/${path}`;
const changesObject = await this.changesCache.getItem<object>(changesCacheKey);
if (changesObject) {
Object.keys(changesObject)
.forEach(key => {
const index = remoteSearchResults.findIndex(x => x["key"] === `${path}/${key}`);
if (index >= 0 && changesObject[key] === null) {
remoteSearchResults.splice(index, 1);
}
});
}
/* Find locally added/changed objects */
const localStateSearchResults = await this.searchLocalState(path, query);
/* Merge local changes search results with remote search results */
const locallyAddedObjects = [];
for (const localStateSearchResultItem of localStateSearchResults.value) {
const searchResultItemIndex = remoteSearchResults.findIndex(r => r["key"] === localStateSearchResultItem["key"]);
if (searchResultItemIndex !== -1) {
remoteSearchResults[searchResultItemIndex] = localStateSearchResultItem;
}
else {
locallyAddedObjects.push(localStateSearchResultItem);
}
}
pageOfRemoteSearchResults.value = locallyAddedObjects.concat(remoteSearchResults);
return pageOfRemoteSearchResults;
}
private convertPage<T>(remotePage: Page<T>): Page<T> {
const resultPage: Page<T> = {
value: remotePage.value,
takeNext: async (n?: number): Promise<Page<T>> => {
const nextRemotePage = await remotePage.takeNext();
return this.convertPage(nextRemotePage);
}
};
if (!remotePage.takeNext) {
resultPage.takeNext = null;
}
return resultPage;
}
public async searchObjects<T>(path: string, query: Query<T> = Query.from<T>()): Promise<Page<T>> {
if (!path) {
throw new Error(`Parameter "path" not specified.`);
}
let resultPage: Page<T>;
switch (this.cachingStrategy) {
case CachingStrategy.NetworkFirst:
resultPage = await this.searchRemote(path, query);
break;
case CachingStrategy.CacheFirst:
resultPage = await this.searchLocalState<T>(path, query);
break;
default:
throw new Error(`Caching strategy "${this.cachingStrategy}" not yet supported.`);
}
return resultPage;
}
public getPrevStateDescription(): string {
if (this.past.length === 0) {
return undefined;
}
const pastState = this.past[this.past.length - 1];
return pastState.stateDescription;
}
public getNextStateDescription(): string {
if (this.future.length === 0) {
return undefined;
}
const futureState = this.future[this.future.length - 1];
return futureState.stateDescription;
}
public async hasUnsavedChanges(): Promise<boolean> {
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
return Object.keys(changesObject).length > 0;
}
public async hasUnsavedChangesAt(key: string): Promise<boolean> {
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
return !!Objects.getObjectAt(key, changesObject);
}
public async discardChanges(): Promise<void> {
const stateObject = await this.stateCache.getItem(stateObjectCacheKey) || {};
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
Object.keys(changesObject).forEach(key => delete changesObject[key]);
Object.keys(stateObject).forEach(key => delete stateObject[key]);
await this.stateCache.setItem(stateObjectCacheKey, stateObject);
await this.changesCache.setItem(changesObjectCacheKey, changesObject);
}
public async saveChanges(): Promise<void> {
const changesObject = await this.changesCache.getItem<object>(changesObjectCacheKey) || {};
const entities = Object.keys(changesObject);
if (entities.length === 0) {
return;
}
await this.remoteObjectStorage.saveChanges(changesObject);
entities.forEach(key => delete changesObject[key]);
await this.changesCache.setItem(changesObjectCacheKey, changesObject);
this.eventManager.dispatchEvent("onDataChange");
}
public async loadData(): Promise<object> {
if (this.remoteObjectStorage.loadData) {
const loadedData = await this.remoteObjectStorage.loadData();
if (loadedData) {
await this.discardChanges();
this.eventManager.dispatchEvent("onDataPush");
this.eventManager.dispatchEvent("onDataChange");
}
}
else {
console.warn("current ObjectStorage does not implement loadData");
}
const data = await this.stateCache.getItem<object>(stateObjectCacheKey);
return data;
}
}