uui-db/src/DbRef.ts (211 lines of code) (raw):

import { Db } from './Db'; import { DbPatch, DbTablesSet, DbSaveResponse, DbView, DbSubscription, } from './types'; import { makeCumulativePatch, unionPatches, mergeEntityPatches, flattenResponse, } from './patchHelpers'; import { TempIdMap, IClientIdsMap } from './tempIds'; import { objectKeys } from './utils'; import isEmpty from 'lodash.isempty'; import { Loader, LoaderOptions } from './Loader'; import { DataQuery } from '@epam/uui-core'; import { SimpleLoadingTracker } from './SimpleLoadingTracker'; import { ListLoadingTracker, ListLoadingTrackerOptions } from './ListLoadingTracker'; import { batch } from './batch'; export class DbRef<TTables extends DbTablesSet<TTables>, TDb extends Db<TTables>> { private base: TDb; private log: { patch: DbPatch<TTables> }[] = []; private autoSave = true; private savedPoint = 0; private tempIdMap: TempIdMap<TTables>; public db: TDb; protected throttleSaveMs = 1000; public readonly idMap: IClientIdsMap; constructor(public blank: TDb) { this.db = blank; this.base = blank; this.tempIdMap = new TempIdMap(blank); this.idMap = this.tempIdMap; window.addEventListener('beforeunload', this.handleBeforeUnload); } /** Saves all committed patches. Useful only if autoSave is off. */ public save() { return this.enqueueSave(); } public getAutoSave() { return this.autoSave; } public setAutoSave(val: boolean) { if (this.autoSave != val) { this.autoSave = val; this.autoSave && this.enqueueSave(); this.update(); } } public revert() { this.db = this.base; this.log = this.log.slice(0, this.savedPoint); this.update(); } /** Concrete DbRef instances should override and implement their save logic in this method */ protected savePatch(patch: DbPatch<TTables>): Promise<DbSaveResponse<TTables>> { return Promise.resolve({ submit: {} }); } /* Db Update logic */ public commit(patch: DbPatch<TTables>): void { const dbBeforeUpdate = this.db.with(patch); patch = this.beforeUpdate(patch, dbBeforeUpdate.tables, this.db.tables); patch = mergeEntityPatches(this.db.tables, patch); this.log.push({ patch }); this.db = this.db.with(patch); this.autoSave && this.enqueueSave(null, { throttleMs: this.throttleSaveMs }); this.update(); } private beforeUpdate(patch: DbPatch<TTables>, tables: TTables, prevTables: TTables): DbPatch<TTables> { let patchAndDependencies: DbPatch<TTables> = {}; objectKeys(tables) .filter((entityName) => patch[entityName] && !tables[entityName].schema.beforeUpdate) .forEach((entityName) => { patchAndDependencies[entityName] = patch[entityName]; }); objectKeys(tables) .filter((entityName) => patch[entityName] && tables[entityName].schema.beforeUpdate) .forEach((entityName) => { const schema = tables[entityName].schema; const { beforeUpdate } = schema; const context = { clientIdsMap: this.tempIdMap, schema, tables, prevTables, }; const results = patch[entityName].map((e) => beforeUpdate(e, context)); const updatedPatch = { [entityName]: results.map((result) => result.entity) } as DbPatch<TTables>; const dependentEntityPatchesForUpdate = unionPatches(results.filter((result) => result.dependentEntities).map((result) => result.dependentEntities)); const dependentEntityPatches = this.beforeUpdate(dependentEntityPatchesForUpdate, tables, prevTables); patchAndDependencies = mergeEntityPatches(tables, unionPatches([ patchAndDependencies, updatedPatch, dependentEntityPatches, ])); }); return patchAndDependencies; } public commitFetch(patch: DbPatch<TTables>): void { patch = this.tempIdMap.serverToClientPatch(patch); this.rebase(this.base.with(patch)); this.update(); } private rebase(newBase: TDb) { this.base = newBase; this.db = newBase; for (let n = this.savedPoint; n < this.log.length; n++) { this.db = this.db.with(this.log[n].patch); } } private makeCumulativePatch() { const lastLogEntry = this.log.length; const logEntriesToSave = this.log.slice(this.savedPoint, lastLogEntry); let cumulativePatch = makeCumulativePatch( this.db, logEntriesToSave.map((t) => t.patch), this.tempIdMap, ); cumulativePatch = this.tempIdMap.clientToServerPatch(cumulativePatch); return cumulativePatch; } private applyResponse(response: DbSaveResponse<TTables>, newSavedPoint: number) { this.tempIdMap.appendServerMapping(response); let serverPatch = flattenResponse(response.submit, this.db.tables); serverPatch = mergeEntityPatches(this.db.tables, serverPatch); this.savedPoint = newSavedPoint; this.base = this.db; this.commitFetch(serverPatch); } /* Update subscriptions (aka live views) */ private lastSubscriptionId = 1; private subscriptions: Map<number, DbSubscription<any, any>> = new Map(); public subscribe<TValue, TParams, TDependencies>( view: DbView<TDb, TValue, TParams, TDependencies>, params: TParams, onUpdate: (newValue: TValue) => any, ): DbSubscription<TValue, TParams> { const id = this.lastSubscriptionId++; const subscription: DbSubscription<TValue, TParams> = { update: (subscriptionParams: TParams) => { subscription.currentParams = subscriptionParams; subscription.currentValue = this.db.runView(view, subscriptionParams); return subscription.currentValue; }, currentParams: null, currentValue: null, onUpdate, unsubscribe: () => { this.subscriptions.delete(id); }, }; subscription.update(params); this.subscriptions.set(id, subscription); return subscription; } updateHandlers: (() => any)[] = []; public onUpdate(handler: () => any) { this.subscribe( { compute: (db) => db, compareResults: (a, b) => a === b, }, null, handler, ); } private update() { this.subscriptions.forEach((subscription) => { const previousValue = subscription.currentValue; subscription.update(subscription.currentParams); if (subscription.currentValue !== previousValue && subscription.onUpdate) { subscription.onUpdate(subscription.currentValue); } }); } /* Error Subscriptions */ public saveErrorHandlers: ((request: DbPatch<TTables>, error: any) => void)[] = []; public onSaveError(handler: (request: DbPatch<TTables>, error: any) => void) { this.saveErrorHandlers.push(handler); } private saveError(request: DbPatch<TTables>, error: any) { this.saveErrorHandlers.forEach((handler) => handler(request, error)); } /* Save scheduling */ private enqueueSave = batch(async () => { const cumulativePatch = this.makeCumulativePatch(); const lastLogEntry = this.log.length; if (isEmpty(cumulativePatch)) { return; } try { const response = await this.savePatch(cumulativePatch); this.applyResponse(response, lastLogEntry); this.update(); } catch (error) { this.saveError(cumulativePatch, error); this.update(); throw error; } }); private handleBeforeUnload = (e: BeforeUnloadEvent) => { if (this.enqueueSave.isBusy) { e.returnValue = false; return false; } }; loaders: Loader<TTables, any, any>[] = []; protected makeLoader<TResponse, TRequest>(options: LoaderOptions<TTables, TResponse, TRequest>) { const loader = new Loader<TTables, TResponse, TRequest>(this as any, () => new SimpleLoadingTracker<TRequest, TResponse>(), options); this.loaders.push(loader); return loader; } protected makeListLoader<TItem, TResponse = DataQuery<TItem>, TRequest extends DataQuery<TItem> = DataQuery<TItem>>( options: LoaderOptions<TTables, TResponse, TRequest> & ListLoadingTrackerOptions<TItem, TResponse>, ) { const loader = new Loader<TTables, TResponse, TRequest>(this as any, () => new ListLoadingTracker<TItem, TRequest, TResponse>(), options); this.loaders.push(loader); return loader; } reload() { this.loaders = []; } }