From 86971ea3c727084fa1a99322afe473d19068bac5 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Mon, 18 Nov 2019 08:55:38 -0800 Subject: [PATCH] Add Database.Servervalue.increment(x) (#2348) * Added ServerValue._increment() for not-yet-working operator (needs server-side rollout and API approval) * Changed server value local resolution to include current offline caches * Add new tests for ServerValues in general + offline increments --- packages/database/src/api/Database.ts | 7 ++ packages/database/src/core/Repo.ts | 4 + .../database/src/core/Repo_transaction.ts | 2 + packages/database/src/core/SyncTree.ts | 11 +-- .../database/src/core/util/ServerValues.ts | 75 ++++++++++++++- packages/database/test/servervalues.test.ts | 95 +++++++++++++++++++ 6 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 packages/database/test/servervalues.test.ts diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index f98909f5c23..8163cfb7c84 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -38,6 +38,13 @@ export class Database implements FirebaseService { static readonly ServerValue = { TIMESTAMP: { '.sv': 'timestamp' + }, + _increment: (x: number) => { + return { + '.sv': { + 'increment': x + } + }; } }; diff --git a/packages/database/src/core/Repo.ts b/packages/database/src/core/Repo.ts index 290e75d03c1..cc33a526055 100644 --- a/packages/database/src/core/Repo.ts +++ b/packages/database/src/core/Repo.ts @@ -307,8 +307,10 @@ export class Repo { // (b) store unresolved paths on JSON parse const serverValues = this.generateServerValues(); const newNodeUnresolved = nodeFromJSON(newVal, newPriority); + const existing = this.serverSyncTree_.calcCompleteEventCache(path); const newNode = resolveDeferredValueSnapshot( newNodeUnresolved, + existing, serverValues ); @@ -359,6 +361,7 @@ export class Repo { const newNodeUnresolved = nodeFromJSON(changedValue); changedChildren[changedKey] = resolveDeferredValueSnapshot( newNodeUnresolved, + this.serverSyncTree_.calcCompleteEventCache(path), serverValues ); }); @@ -413,6 +416,7 @@ export class Repo { const serverValues = this.generateServerValues(); const resolvedOnDisconnectTree = resolveDeferredValueTree( this.onDisconnect_, + this.serverSyncTree_, serverValues ); let events: Event[] = []; diff --git a/packages/database/src/core/Repo_transaction.ts b/packages/database/src/core/Repo_transaction.ts index c53def71421..518a06bea8a 100644 --- a/packages/database/src/core/Repo_transaction.ts +++ b/packages/database/src/core/Repo_transaction.ts @@ -245,6 +245,7 @@ Repo.prototype.startTransaction = function( const newNodeUnresolved = nodeFromJSON(newVal, priorityForNode); const newNode = resolveDeferredValueSnapshot( newNodeUnresolved, + currentState, serverValues ); transaction.currentOutputSnapshotRaw = newNodeUnresolved; @@ -522,6 +523,7 @@ Repo.prototype.startTransaction = function( const serverValues = this.generateServerValues(); const newNodeResolved = resolveDeferredValueSnapshot( newDataNode, + currentNode, serverValues ); diff --git a/packages/database/src/core/SyncTree.ts b/packages/database/src/core/SyncTree.ts index 0e5036a4ce1..7cdb874754c 100644 --- a/packages/database/src/core/SyncTree.ts +++ b/packages/database/src/core/SyncTree.ts @@ -474,18 +474,17 @@ export class SyncTree { } /** - * Returns a complete cache, if we have one, of the data at a particular path. The location must have a listener above - * it, but as this is only used by transaction code, that should always be the case anyways. + * Returns a complete cache, if we have one, of the data at a particular path. If the location does not have a + * listener above it, we will get a false "null". This shouldn't be a problem because transactions will always + * have a listener above, and atomic operations would correctly show a jitter of -> + * as the write is applied locally and then acknowledged at the server. * * Note: this method will *include* hidden writes from transaction with applyLocally set to false. * * @param path The path to the data we want * @param writeIdsToExclude A specific set to be excluded */ - calcCompleteEventCache( - path: Path, - writeIdsToExclude?: number[] - ): Node | null { + calcCompleteEventCache(path: Path, writeIdsToExclude?: number[]): Node { const includeHiddenSets = true; const writeTree = this.pendingWriteTree_; const serverCache = this.syncPointTree_.findOnPath(path, function( diff --git a/packages/database/src/core/util/ServerValues.ts b/packages/database/src/core/util/ServerValues.ts index 42ac76e3a59..72af40ac359 100644 --- a/packages/database/src/core/util/ServerValues.ts +++ b/packages/database/src/core/util/ServerValues.ts @@ -23,6 +23,7 @@ import { nodeFromJSON } from '../snap/nodeFromJSON'; import { PRIORITY_INDEX } from '../snap/indexes/PriorityIndex'; import { Node } from '../snap/Node'; import { ChildrenNode } from '../snap/ChildrenNode'; +import { SyncTree } from '../SyncTree'; /** * Generate placeholders for deferred values. @@ -48,16 +49,64 @@ export const generateWithValues = function( */ export const resolveDeferredValue = function( value: { [k: string]: any } | string | number | boolean, + existing: Node, serverValues: { [k: string]: any } ): string | number | boolean { if (!value || typeof value !== 'object') { return value as string | number | boolean; + } + assert('.sv' in value, 'Unexpected leaf node or priority contents'); + + if (typeof value['.sv'] === 'string') { + return resolveScalarDeferredValue(value['.sv'], existing, serverValues); + } else if (typeof value['.sv'] === 'object') { + return resolveComplexDeferredValue(value['.sv'], existing, serverValues); } else { - assert('.sv' in value, 'Unexpected leaf node or priority contents'); - return serverValues[value['.sv']]; + assert(false, 'Unexpected server value: ' + JSON.stringify(value, null, 2)); + } +}; + +const resolveScalarDeferredValue = function( + op: string, + existing: Node, + serverValues: { [k: string]: any } +): string | number | boolean { + switch (op) { + case 'timestamp': + return serverValues['timestamp']; + default: + assert(false, 'Unexpected server value: ' + op); } }; +const resolveComplexDeferredValue = function( + op: Object, + existing: Node, + unused: { [k: string]: any } +): string | number | boolean { + if (!op.hasOwnProperty('increment')) { + assert(false, 'Unexpected server value: ' + JSON.stringify(op, null, 2)); + } + const delta = op['increment']; + if (typeof delta !== 'number') { + assert(false, 'Unexpected increment value: ' + delta); + } + + // Incrementing a non-number sets the value to the incremented amount + if (!existing.isLeafNode()) { + return delta; + } + + const leaf = existing as LeafNode; + const existingVal = leaf.getValue(); + if (typeof existingVal !== 'number') { + return delta; + } + + // No need to do over/underflow arithmetic here because JS only handles floats under the covers + return existingVal + delta; +}; + /** * Recursively replace all deferred values and priorities in the tree with the * specified generated replacement values. @@ -67,13 +116,19 @@ export const resolveDeferredValue = function( */ export const resolveDeferredValueTree = function( tree: SparseSnapshotTree, + syncTree: SyncTree, serverValues: Object ): SparseSnapshotTree { const resolvedTree = new SparseSnapshotTree(); tree.forEachTree(new Path(''), function(path, node) { + const existing = syncTree.calcCompleteEventCache(path); + assert( + existing !== null && typeof existing !== 'undefined', + 'Expected ChildrenNode.EMPTY_NODE for nulls' + ); resolvedTree.remember( path, - resolveDeferredValueSnapshot(node, serverValues) + resolveDeferredValueSnapshot(node, existing, serverValues) ); }); return resolvedTree; @@ -89,6 +144,7 @@ export const resolveDeferredValueTree = function( */ export const resolveDeferredValueSnapshot = function( node: Node, + existing: Node, serverValues: Object ): Node { const rawPri = node.getPriority().val() as @@ -97,12 +153,20 @@ export const resolveDeferredValueSnapshot = function( | null | number | string; - const priority = resolveDeferredValue(rawPri, serverValues); + const priority = resolveDeferredValue( + rawPri, + existing.getPriority(), + serverValues + ); let newNode: Node; if (node.isLeafNode()) { const leafNode = node as LeafNode; - const value = resolveDeferredValue(leafNode.getValue(), serverValues); + const value = resolveDeferredValue( + leafNode.getValue(), + existing, + serverValues + ); if ( value !== leafNode.getValue() || priority !== leafNode.getPriority().val() @@ -120,6 +184,7 @@ export const resolveDeferredValueSnapshot = function( childrenNode.forEachChild(PRIORITY_INDEX, function(childName, childNode) { const newChildNode = resolveDeferredValueSnapshot( childNode, + existing.getImmediateChild(childName), serverValues ); if (newChildNode !== childNode) { diff --git a/packages/database/test/servervalues.test.ts b/packages/database/test/servervalues.test.ts new file mode 100644 index 00000000000..be5d1191c4b --- /dev/null +++ b/packages/database/test/servervalues.test.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2019 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { getRandomNode } from './helpers/util'; +import { Database } from '../src/api/Database'; +import { Reference } from '../src/api/Reference'; +import { nodeFromJSON } from '../src/core/snap/nodeFromJSON'; + +describe('ServerValue tests', () => { + it('resolves timestamps locally', async () => { + const node = getRandomNode() as Reference; + const start = Date.now(); + const values: Array = []; + node.on('value', snap => { + expect(typeof snap.val()).to.equal('number'); + values.push(snap.val() as number); + }); + await node.set(Database.ServerValue.TIMESTAMP); + node.off('value'); + + // By the time the write is acknowledged, we should have a local and + // server version of the timestamp. + expect(values.length).to.equal(2); + values.forEach(serverTime => { + const delta = Math.abs(serverTime - start); + expect(delta).to.be.lessThan(1000); + }); + }); + + it('handles increments without listeners', () => { + // Ensure that increments don't explode when the SyncTree must return a null + // node (i.e. ChildrenNode.EMPTY_NODE) because there is not yet any synced + // data. + const node = getRandomNode() as Reference; + const addOne = Database.ServerValue._increment(1); + + node.set(addOne); + }); + + it('handles increments locally', async () => { + const node = getRandomNode() as Reference; + const addOne = Database.ServerValue._increment(1); + + // Must go offline because the latest emulator may not support this server op + // This also means we can't await node operations, which would block the test. + node.database.goOffline(); + try { + const values: Array = []; + const expected: Array = []; + node.on('value', snap => values.push(snap.val())); + + // null -> increment(x) = x + node.set(addOne); + expected.push(1); + + // x -> increment(y) = x + y + node.set(5); + node.set(addOne); + expected.push(5); + expected.push(6); + + // str -> increment(x) = x + node.set('hello'); + node.set(addOne); + expected.push('hello'); + expected.push(1); + + // obj -> increment(x) = x + node.set({ 'hello': 'world' }); + node.set(addOne); + expected.push({ 'hello': 'world' }); + expected.push(1); + + node.off('value'); + expect(values).to.deep.equal(expected); + } finally { + node.database.goOnline(); + } + }); +});