diff --git a/config/environment.default.js b/config/environment.default.js index 22a70f3513..32edd1391d 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -20,7 +20,12 @@ module.exports = { // NOTE: how long should objects be cached for by default msToLive: 15 * 60 * 1000, // 15 minutes // msToLive: 1000, // 15 minutes - control: 'max-age=60' // revalidate browser + control: 'max-age=60', // revalidate browser + autoSync: { + defaultTime: 0, + maxBufferSize: 100, + timePerMethod: {'Patch': 30} //time in seconds + } }, // Form settings form: { diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index 957c0f7269..024a0e7061 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -11,7 +11,8 @@ export const ObjectCacheActionTypes = { ADD: type('dspace/core/cache/object/ADD'), REMOVE: type('dspace/core/cache/object/REMOVE'), RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'), - PATCH: type('dspace/core/cache/object/PATCH') + ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), + APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH') }; /* tslint:disable:max-classes-per-file */ @@ -56,11 +57,11 @@ export class RemoveFromObjectCacheAction implements Action { /** * Create a new RemoveFromObjectCacheAction * - * @param uuid - * the UUID of the object to remove + * @param href + * the unique href of the object to remove */ - constructor(uuid: string) { - this.payload = uuid; + constructor(href: string) { + this.payload = href; } } @@ -83,25 +84,43 @@ export class ResetObjectCacheTimestampsAction implements Action { } /** - * An ngrx action to add new operations to a specified cached objects + * An ngrx action to add new operations to a specified cached object */ -export class PatchObjectCacheAction implements Action { - type = ObjectCacheActionTypes.PATCH; +export class AddPatchObjectCacheAction implements Action { + type = ObjectCacheActionTypes.ADD_PATCH; payload: { - uuid: string, + href: string, operations: Operation[] }; /** - * Create a new PatchObjectCacheAction + * Create a new AddPatchObjectCacheAction * - * @param uuid - * the uuid of the object that should be updated + * @param href + * the unique href of the object that should be updated * @param operations * the list of operations to add */ - constructor(uuid: string, operations: Operation[]) { - this.payload = { uuid, operations }; + constructor(href: string, operations: Operation[]) { + this.payload = { href, operations }; + } +} + +/** + * An ngrx action to apply all existing operations to a specified cached object + */ +export class ApplyPatchObjectCacheAction implements Action { + type = ObjectCacheActionTypes.APPLY_PATCH; + payload: string; + + /** + * Create a new ApplyPatchObjectCacheAction + * + * @param href + * the unique href of the object that should be updated + */ + constructor(href: string) { + this.payload = href; } } @@ -114,4 +133,5 @@ export type ObjectCacheAction = AddToObjectCacheAction | RemoveFromObjectCacheAction | ResetObjectCacheTimestampsAction - | PatchObjectCacheAction; + | AddPatchObjectCacheAction + | ApplyPatchObjectCacheAction; diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 5cc4b0551a..cd0a927c34 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,11 +1,15 @@ import { - ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, - RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction, PatchObjectCacheAction + ObjectCacheAction, + ObjectCacheActionTypes, + AddToObjectCacheAction, + RemoveFromObjectCacheAction, + ResetObjectCacheTimestampsAction, + AddPatchObjectCacheAction, ApplyPatchObjectCacheAction } from './object-cache.actions'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheEntry } from './cache-entry'; import { ResourceType } from '../shared/resource-type'; -import { Operation } from 'fast-json-patch'; +import { applyPatch, Operation } from 'fast-json-patch'; export enum DirtyType { Created = 'Created', @@ -13,7 +17,11 @@ export enum DirtyType { Deleted = 'Deleted' } -/** +export interface Patch { + uuid?: string; + operations: Operation[]; +} +/**conca * An interface to represent objects that can be cached * * A cacheable object should have a self link @@ -37,7 +45,8 @@ export class ObjectCacheEntry implements CacheEntry { timeAdded: number; msToLive: number; requestHref: string; - operations: Operation[]; + patches: Patch[] = []; + isDirty: boolean; } /** @@ -78,9 +87,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi return resetObjectCacheTimestamps(state, action as ResetObjectCacheTimestampsAction) } - case ObjectCacheActionTypes.PATCH: { - return patchObjectCache(state, action as PatchObjectCacheAction); + case ObjectCacheActionTypes.ADD_PATCH: { + return addPatchObjectCache(state, action as AddPatchObjectCacheAction); } + + case ObjectCacheActionTypes.APPLY_PATCH: { + return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction); + } + default: { return state; } @@ -104,7 +118,7 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio timeAdded: action.payload.timeAdded, msToLive: action.payload.msToLive, requestHref: action.payload.requestHref, - operations: [] + isDirty: false } }); } @@ -156,16 +170,41 @@ function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObject * @param state * the current state * @param action - * a PatchObjectCacheAction + * an AddPatchObjectCacheAction * @return ObjectCacheState * the new state, with the new operations added to the state of the specified ObjectCacheEntry */ -function patchObjectCache(state: ObjectCacheState, action: PatchObjectCacheAction): ObjectCacheState { - const uuid = action.payload.uuid; +function addPatchObjectCache(state: ObjectCacheState, action: AddPatchObjectCacheAction): ObjectCacheState { + const uuid = action.payload.href; const operations = action.payload.operations; const newState = Object.assign({}, state); if (hasValue(newState[uuid])) { - newState[uuid].operations = state[uuid].operations.concat(operations); + const patches = newState[uuid].patches; + newState[uuid].patches = [...patches, {operations} as Patch]; + newState[uuid].isDirty = true; + } + return newState; +} + +/** + * Apply the list of patch operations to a cached object + * + * @param state + * the current state + * @param action + * an ApplyPatchObjectCacheAction + * @return ObjectCacheState + * the new state, with the new operations applied to the state of the specified ObjectCacheEntry + */ +function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObjectCacheAction): ObjectCacheState { + const uuid = action.payload; + const newState = Object.assign({}, state); + if (hasValue(newState[uuid])) { + // flatten two dimensional array + const flatPatch: Operation[] = [].concat(...newState[uuid].patches); + const newData = applyPatch( newState[uuid].data, flatPatch); + newState[uuid].data = newData.newDocument; + newState[uuid].patches = []; } return newState; } diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index d9b3077d55..d7bf59500c 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -7,8 +7,8 @@ import { IndexName } from '../index/index.reducer'; import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer'; import { - AddToObjectCacheAction, - PatchObjectCacheAction, + AddPatchObjectCacheAction, + AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; @@ -18,13 +18,15 @@ import { pathSelector } from '../shared/selectors'; import { NormalizedObjectFactory } from './models/normalized-object-factory'; import { NormalizedObject } from './models/normalized-object.model'; import { applyPatch, Operation } from 'fast-json-patch'; +import { RestRequestMethod } from '../data/request.models'; +import { AddToSSBAction } from './server-sync-buffer.actions'; function selfLinkFromUuidSelector(uuid: string): MemoizedSelector { return pathSelector(coreSelector, 'index', IndexName.OBJECT, uuid); } function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector { - return pathSelector(coreSelector, 'data/object', selfLink); + return pathSelector(coreSelector, 'cache/object', selfLink); } /** @@ -52,10 +54,10 @@ export class ObjectCacheService { } /** - * Remove the object with the supplied UUID from the cache + * Remove the object with the supplied href from the cache * - * @param uuid - * The UUID of the object to be removed + * @param href + * The unique href of the object to be removed */ remove(uuid: string): void { this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); @@ -87,13 +89,17 @@ export class ObjectCacheService { getBySelfLink(selfLink: string): Observable { return this.getEntry(selfLink).pipe( + map((entry: ObjectCacheEntry) => { + // flatten two dimensional array + const flatPatch: Operation[] = [].concat(...entry.patches); + const patchedData = applyPatch(entry.data, flatPatch).newDocument; + return Object.assign({}, entry, { data: patchedData }); + } + ), map((entry: ObjectCacheEntry) => { const type: GenericConstructor = NormalizedObjectFactory.getConstructor(entry.data.type); return Object.assign(new type(), entry.data) as T - }), - // map((entry: ObjectCacheEntry) => - // applyPatch(entry.data, entry.operations).newDocument - // ) + }) ); } @@ -205,14 +211,16 @@ export class ObjectCacheService { } /** - * Add operations to a the existing list of operations for an ObjectCacheEntry + * Add operations to the existing list of operations for an ObjectCacheEntry + * Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated * @param {string} uuid * the uuid of the ObjectCacheEntry * @param {Operation[]} patch * list of operations to perform */ - private addOperations(uuid: string, patch: Operation[]) { - this.store.dispatch(new PatchObjectCacheAction(uuid, patch)); + private addPatch(uuid: string, patch: Operation[]) { + this.store.dispatch(new AddPatchObjectCacheAction(uuid, patch)); + this.store.dispatch(new AddToSSBAction(uuid, RestRequestMethod.Patch)); } /** @@ -224,6 +232,17 @@ export class ObjectCacheService { * false if the entry is there are no operations left in the ObjectCacheEntry, true otherwise */ private isDirty(entry: ObjectCacheEntry): boolean { - return isNotEmpty(entry.operations); + return isNotEmpty(entry.patches); } + + /** + * Apply the existing operations on an ObjectCacheEntry in the store + * NB: this does not make any server side changes + * @param {string} uuid + * the uuid of the ObjectCacheEntry + */ + private applyPatchesToCachedObject(uuid: string) { + this.store.dispatch(new ApplyPatchObjectCacheAction(uuid)); + } + } diff --git a/src/app/core/cache/response-cache.service.ts b/src/app/core/cache/response-cache.service.ts index 21430d451c..973d3620ff 100644 --- a/src/app/core/cache/response-cache.service.ts +++ b/src/app/core/cache/response-cache.service.ts @@ -12,7 +12,7 @@ import { coreSelector, CoreState } from '../core.reducers'; import { pathSelector } from '../shared/selectors'; function entryFromKeySelector(key: string): MemoizedSelector { - return pathSelector(coreSelector, 'data/response', key); + return pathSelector(coreSelector, 'cache/response', key); } /** diff --git a/src/app/core/cache/server-sync-buffer.actions.ts b/src/app/core/cache/server-sync-buffer.actions.ts new file mode 100644 index 0000000000..9189af4471 --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.actions.ts @@ -0,0 +1,83 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; +import { CacheableObject } from './object-cache.reducer'; +import { Operation } from 'fast-json-patch'; +import { RestRequest, RestRequestMethod } from '../data/request.models'; + +/** + * The list of ServerSyncBufferAction type definitions + */ +export const ServerSyncBufferActionTypes = { + ADD: type('dspace/core/cache/syncbuffer/ADD'), + COMMIT: type('dspace/core/cache/syncbuffer/COMMIT'), + EMPTY: type('dspace/core/cache/syncbuffer/EMPTY'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * An ngrx action to add a new cached object to the server's sync buffer + */ +export class AddToSSBAction implements Action { + type = ServerSyncBufferActionTypes.ADD; + payload: { + href: string, + method: RestRequestMethod + }; + + /** + * Create a new AddToSSBAction + * + * @param href + * the unique href of the cached object entry that should be updated + */ + constructor(href: string, method: RestRequestMethod) { + this.payload = { href, method }; + } +} + +/** + * An ngrx action to commit everything (for a certain method, when specified) in the ServerSyncBuffer to the server + */ +export class CommitSSBAction implements Action { + type = ServerSyncBufferActionTypes.COMMIT; + payload?: RestRequestMethod; + + /** + * Create a new CommitSSBAction + * + * @param method + * an optional method for which the ServerSyncBuffer should send its entries to the server + */ + constructor(method?: RestRequestMethod) { + this.payload = method; + } +} +/** + * An ngrx action to remove everything (for a certain method, when specified) from the ServerSyncBuffer to the server + */ +export class EmptySSBAction implements Action { + type = ServerSyncBufferActionTypes.EMPTY; + payload?: RestRequestMethod; + + /** + * Create a new EmptySSBAction + * + * @param method + * an optional method for which the ServerSyncBuffer should remove its entries + */ + constructor(method?: RestRequestMethod) { + this.payload = method; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all ServerSyncBufferActions + */ +export type ServerSyncBufferAction + = AddToSSBAction + | CommitSSBAction + | EmptySSBAction diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts new file mode 100644 index 0000000000..517c9837c2 --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -0,0 +1,55 @@ +import { delay, exhaustMap, filter, first, map } from 'rxjs/operators'; +import { Inject, Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { + AddToSSBAction, + CommitSSBAction, + EmptySSBAction, + ServerSyncBufferActionTypes +} from './server-sync-buffer.actions'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { CoreState } from '../core.reducers'; +import { select, Store } from '@ngrx/store'; +import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; +import { of as observableOf } from 'rxjs'; +import { RequestService } from '../data/request.service'; +import { PutRequest } from '../data/request.models'; + +@Injectable() +export class ObjectCacheEffects { + @Effect() setTimeoutForServerSync = this.actions$ + .pipe(ofType(ServerSyncBufferActionTypes.ADD), + exhaustMap((action: AddToSSBAction) => { + const autoSyncConfig = this.EnvConfig.cache.autoSync; + const timeoutInSeconds = autoSyncConfig.timePerMethod[action.type] || autoSyncConfig.defaultTime; + return observableOf(new CommitSSBAction(action.payload.method)).pipe(delay( timeoutInSeconds * 1000)) + }) + ); + + @Effect() commitServerSyncBuffer = this.actions$ + .pipe(ofType(ServerSyncBufferActionTypes.COMMIT), + map((action: CommitSSBAction) => { + this.store.pipe( + select(serverSyncBufferSelector), + first() + ).subscribe((bufferState: ServerSyncBufferState) => { + bufferState.buffer + .filter((entry: ServerSyncBufferEntry) => entry.method === action.payload) + .forEach((entry: ServerSyncBufferEntry) => { + this.requestService.configure(new PutRequest(this.requestService.generateRequestId(), ,)) + }) + }); + return new EmptySSBAction(action.payload); + }) + ); + + constructor(private actions$: Actions, + private store: Store, + private requestService: RequestService, + @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) { + + } +} + +export const serverSyncBufferSelector = (state: CoreState) => state['cache/syncbuffer']; diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts new file mode 100644 index 0000000000..1fabe5a50a --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -0,0 +1,92 @@ +import { RestRequestMethod } from '../data/request.models'; +import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { + AddToSSBAction, + EmptySSBAction, + ServerSyncBufferAction, + ServerSyncBufferActionTypes +} from './server-sync-buffer.actions'; + +/** + * An entry in the ServerSyncBufferState + * href: unique href of an ObjectCacheEntry + * method: RestRequestMethod type + */ +export class ServerSyncBufferEntry { + href: string; + method: RestRequestMethod; +} + +/** + * The ServerSyncBuffer State + * + * Consists list of ServerSyncBufferState + */ +export interface ServerSyncBufferState { + buffer: ServerSyncBufferEntry[]; +} + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState: ServerSyncBufferState = { buffer: [] }; + +/** + * The ServerSyncBuffer Reducer + * + * @param state + * the current state + * @param action + * the action to perform on the state + * @return ServerSyncBufferState + * the new state + */ +export function serverSyncBufferReducer(state = initialState, action: ServerSyncBufferAction): ServerSyncBufferState { + switch (action.type) { + + case ServerSyncBufferActionTypes.ADD: { + return addToServerSyncQueue(state, action as AddToSSBAction) + } + + case ServerSyncBufferActionTypes.EMPTY: { + return emptyServerSyncQueue(state, action as EmptySSBAction); + } + default: { + return state; + } + } +} + +/** + * Add a new entry to the buffer with a specified method + * + * @param state + * the current state + * @param action + * an AddToSSBAction + * @return ServerSyncBufferState + * the new state, with a new entry added to the buffer + */ +function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBAction): ServerSyncBufferState { + const actionEntry = action.payload as ServerSyncBufferEntry; + if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) { + return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) }); + } +} + +/** + * Remove all ServerSyncBuffers entry from the buffer with a specified method + * If no method is specified, empty the whole buffer + * + * @param state + * the current state + * @param action + * an AddToSSBAction + * @return ServerSyncBufferState + * the new state, with a new entry added to the buffer + */ +function emptyServerSyncQueue(state: ServerSyncBufferState, action: EmptySSBAction): ServerSyncBufferState { + let newBuffer = []; + if (hasValue(action.payload)) { + newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); + } + return Object.assign({}, state, { buffer: newBuffer }); +} diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c764a2acff..6905eb1300 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -5,18 +5,21 @@ import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reduc import { indexReducer, IndexState } from './index/index.reducer'; import { requestReducer, RequestState } from './data/request.reducer'; import { authReducer, AuthState } from './auth/auth.reducer'; +import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; export interface CoreState { - 'data/object': ObjectCacheState, - 'data/response': ResponseCacheState, + 'cache/object': ObjectCacheState, + 'cache/response': ResponseCacheState, + 'cache/syncbuffer': ServerSyncBufferState, 'data/request': RequestState, 'index': IndexState, 'auth': AuthState, } export const coreReducers: ActionReducerMap = { - 'data/object': objectCacheReducer, - 'data/response': responseCacheReducer, + 'cache/object': objectCacheReducer, + 'cache/response': responseCacheReducer, + 'cache/syncbuffer': serverSyncBufferReducer, 'data/request': requestReducer, 'index': indexReducer, 'auth': authReducer diff --git a/src/config/auto-sync-config.interface.ts b/src/config/auto-sync-config.interface.ts new file mode 100644 index 0000000000..4ad74005bf --- /dev/null +++ b/src/config/auto-sync-config.interface.ts @@ -0,0 +1,12 @@ +import { RestRequestMethod } from '../app/core/data/request.models'; + +/* enum indices */ +type TimePerMethod = { + [method in RestRequestMethod]: number; +}; + +export interface AutoSyncConfig { + defaultTime: number; + timePerMethod: TimePerMethod; + maxBufferSize: number; +}; diff --git a/src/config/cache-config.interface.ts b/src/config/cache-config.interface.ts index f8a2c88640..3e9334e300 100644 --- a/src/config/cache-config.interface.ts +++ b/src/config/cache-config.interface.ts @@ -1,6 +1,8 @@ import { Config } from './config.interface'; +import { AutoSyncConfig } from './auto-sync-config.interface'; export interface CacheConfig extends Config { msToLive: number, - control: string + control: string, + autoSync: AutoSyncConfig }