diff --git a/client/lib/json-patch-lib.ts b/client/lib/json-patch-lib.ts index a961aa0..10dc42f 100644 --- a/client/lib/json-patch-lib.ts +++ b/client/lib/json-patch-lib.ts @@ -1,8 +1,4 @@ -import { PatchOp } from './types' - -export interface Validator { - (operation: Operation, index: number, doc: T, existingPathFragment: string): void -} +import { DBCache, PatchOp } from './types' export interface BaseOperation { path: string @@ -22,68 +18,68 @@ export interface ReplaceOperation extends BaseOperation { value: T } -export type Doc = { [key: string]: any } +export type Operation = AddOperation | RemoveOperation | ReplaceOperation -export type Operation = AddOperation | RemoveOperation | ReplaceOperation +export function getValueByPointer> (data: T, pointer: string): any { + if (pointer === '/') return data -export function getValueByPointer (doc: any, pointer: string): any { - if (pointer === '/') return doc - const pathArr = pointer.split('/') - pathArr.shift() try { - return pathArr.reduce((acc, next) => acc[next], doc) + return jsonPathToKeyArray(pointer).reduce((acc, next) => acc[next], data) } catch (e) { return undefined } } -export function applyOperation (doc: Doc, op: Operation): Operation | null { - let undo: Operation | null = null - const pathArr = op.path.split('/') - pathArr.shift() - pathArr.reduce((node, key, i) => { - if (!isObject) { - throw Error('patch cannot be applied. Path contains non object') - } +export function applyOperation ( + doc: DBCache>, + { path, op, value }: Operation & { value?: T }, +): Operation | null { + const current = getValueByPointer(doc.data, path) + const remove = { op: PatchOp.REMOVE, path} as const + const add = { op: PatchOp.ADD, path, value: current} as const + const replace = { op: PatchOp.REPLACE, path, value: current } as const - if (i < pathArr.length - 1) { - // iterate node - return node[key] - } + doc.data = recursiveApply(doc.data, jsonPathToKeyArray(path), value) - // if last key - const curVal = node[key] - if (op.op === 'add' || op.op === 'replace') { - node[key] = op.value - if (curVal) { - undo = { - op: PatchOp.REPLACE, - path: op.path, - value: curVal, - } - } else { - undo = { - op: PatchOp.REMOVE, - path: op.path, - } - } - } else { - delete node[key] - if (curVal) { - undo = { - op: PatchOp.ADD, - path: op.path, - value: curVal, - } - } - } - }, doc) - - return undo + switch (op) { + case PatchOp.REMOVE: + return current === undefined + ? null + : add + case PatchOp.REPLACE: + case PatchOp.ADD: + return current === undefined + ? remove + : replace + } } -function isObject (val: any): val is Doc { +function recursiveApply> (data: T, path: readonly string[], value?: any): T { + if (!path.length) return value + + if (!isObject(data)) { + throw Error('Patch cannot be applied. Path contains non object') + } + + const updated = recursiveApply(data[path[0]], path.slice(1), value) + const result = { + ...data, + [path[0]]: updated, + } + + if (updated === undefined) { + delete result[path[0]] + } + + return result +} + +function isObject (val: any): val is Record { return typeof val === 'object' && !Array.isArray(val) && val !== null } +function jsonPathToKeyArray (path: string): string[] { + return path.split('/').slice(1) +} + diff --git a/client/lib/patch-db.ts b/client/lib/patch-db.ts index 6643efd..501db48 100644 --- a/client/lib/patch-db.ts +++ b/client/lib/patch-db.ts @@ -1,4 +1,4 @@ -import { merge, Observable, Subject, Subscription } from 'rxjs' +import { merge, Observable, ReplaySubject, Subject, Subscription } from 'rxjs' import { Store } from './store' import { DBCache, Http } from './types' import { RPCError } from './source/ws-source' @@ -8,7 +8,7 @@ export class PatchDB { public store: Store = new Store(this.http, this.initialCache) public connectionError$ = new Subject() public rpcError$ = new Subject() - public cache$ = new Subject>() + public cache$ = new ReplaySubject>(1) private updatesSub?: Subscription private sourcesSub = this.sources$.subscribe(sources => { @@ -36,8 +36,6 @@ export class PatchDB { clean () { this.sourcesSub.unsubscribe() - if (this.updatesSub) { - this.updatesSub.unsubscribe() - } + this.updatesSub?.unsubscribe() } } diff --git a/client/lib/source/mock-source.ts b/client/lib/source/mock-source.ts index e4d12bb..751dd4e 100644 --- a/client/lib/source/mock-source.ts +++ b/client/lib/source/mock-source.ts @@ -1,13 +1,13 @@ -import { Observable } from "rxjs"; -import { map } from "rxjs/operators"; -import { Update } from "../types"; -import { Source } from "./source"; -import { RPCResponse } from "./ws-source"; +import { Observable } from 'rxjs' +import { map } from 'rxjs/operators' +import { Update } from '../types' +import { Source } from './source' +import { RPCResponse } from './ws-source' export class MockSource implements Source { - constructor(private readonly seed: Observable>) {} + constructor (private readonly seed: Observable>) { } - watch$(): Observable>> { - return this.seed.pipe(map((result) => ({ result, jsonrpc: "2.0" }))); + watch$ (): Observable>> { + return this.seed.pipe(map((result) => ({ result, jsonrpc: '2.0' }))) } } diff --git a/client/lib/source/poll-source.ts b/client/lib/source/poll-source.ts index 41d548a..a405cd6 100644 --- a/client/lib/source/poll-source.ts +++ b/client/lib/source/poll-source.ts @@ -1,4 +1,4 @@ -import { BehaviorSubject, concat, from, Observable, of, Subject } from 'rxjs' +import { BehaviorSubject, concat, from, Observable, of } from 'rxjs' import { concatMap, delay, map, skip, switchMap, take, tap } from 'rxjs/operators' import { Store } from '../store' import { Http, Update } from '../types' diff --git a/client/lib/source/ws-source.ts b/client/lib/source/ws-source.ts index fdb73db..351c3ad 100644 --- a/client/lib/source/ws-source.ts +++ b/client/lib/source/ws-source.ts @@ -43,14 +43,6 @@ export interface RPCError extends RPCBase { } export type RPCResponse = RPCSuccess | RPCError -function isRpcError (arg: { error: Error } | { result: Result}): arg is { error: Error } { - return !!(arg as any).error -} - -function isRpcSuccess (arg: { error: Error } | { result: Result}): arg is { result: Result } { - return !!(arg as any).result -} - class RpcError { code: number message: string @@ -61,4 +53,4 @@ class RpcError { this.message = e.message this.details = e.data.details } -} \ No newline at end of file +} diff --git a/client/lib/store.ts b/client/lib/store.ts index d8cabd0..aa5a7eb 100644 --- a/client/lib/store.ts +++ b/client/lib/store.ts @@ -5,7 +5,7 @@ import BTree from 'sorted-btree' export interface StashEntry { revision: Revision - undo: Operation[] + undo: Operation[] } export class Store { @@ -70,20 +70,12 @@ export class Store { this.processStashed(revision.id) } - private handleDump (dump: Dump): void { - Object.keys(this.cache.data).forEach(key => { - if (dump.value[key] === undefined) { - delete this.cache.data[key] - } - }) - - Object.entries(dump.value).forEach(([key, val]) => { - (this.cache.data as any)[key] = val - }) - this.stash.deleteRange(this.cache.sequence, dump.id, false) + private handleDump ({ value, id }: Dump): void { + this.cache.data = { ...value } + this.stash.deleteRange(this.cache.sequence, id, false) this.updateWatchedNodes('') - this.updateSequence(dump.id) - this.processStashed(dump.id + 1) + this.updateSequence(id) + this.processStashed(id + 1) } private processStashed (id: number): void { @@ -95,9 +87,7 @@ export class Store { let stashEntry = this.stash.get(this.stash.maxKey() as number) while (stashEntry && stashEntry.revision.id > id) { - stashEntry.undo.forEach(u => { - applyOperation(this.cache.data, u) - }) + stashEntry.undo.forEach(u => applyOperation(this.cache, u)) stashEntry = this.stash.nextLowerPair(stashEntry.revision.id)?.[1] } } @@ -105,19 +95,17 @@ export class Store { private applyRevisions (id: number): void { let revision = this.stash.get(id)?.revision while (revision) { - let undo: Operation[] = [] - + let undo: Operation[] = [] let success = false + try { revision.patch.forEach(op => { - const u = applyOperation(this.cache.data, op) + const u = applyOperation(this.cache, op) if (u) undo.push(u) }) success = true } catch (e) { - undo.forEach(u => { - applyOperation(this.cache.data, u) - }) + undo.forEach(u => applyOperation(this.cache, u)) undo = [] } @@ -163,6 +151,6 @@ export class Store { } private isRevision (update: Update): update is Revision { - return !!(update as Revision).patch + return 'patch' in update } -} \ No newline at end of file +} diff --git a/client/lib/types.ts b/client/lib/types.ts index 278aaa9..ba8d61a 100644 --- a/client/lib/types.ts +++ b/client/lib/types.ts @@ -1,7 +1,7 @@ import { Operation } from './json-patch-lib' // revise a collection of nodes. -export type Revision = { id: number, patch: Operation[], expireId: string | null } +export type Revision = { id: number, patch: Operation[], expireId: string | null } // dump/replace the entire store with T export type Dump = { id: number, value: T, expireId: string | null } @@ -23,7 +23,7 @@ export interface Bootstrapper { update (cache: DBCache): Promise } -export interface DBCache{ +export interface DBCache>{ sequence: number, data: T } diff --git a/client/package-lock.json b/client/package-lock.json index eeb52ec..df9e420 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -383,9 +383,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "node_modules/mkdirp": { @@ -905,9 +905,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "mkdirp": {