Skip to content

Commit 86971ea

Browse files
authored
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
1 parent 9ccc3dc commit 86971ea

File tree

6 files changed

+183
-11
lines changed

6 files changed

+183
-11
lines changed

packages/database/src/api/Database.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export class Database implements FirebaseService {
3838
static readonly ServerValue = {
3939
TIMESTAMP: {
4040
'.sv': 'timestamp'
41+
},
42+
_increment: (x: number) => {
43+
return {
44+
'.sv': {
45+
'increment': x
46+
}
47+
};
4148
}
4249
};
4350

packages/database/src/core/Repo.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,10 @@ export class Repo {
307307
// (b) store unresolved paths on JSON parse
308308
const serverValues = this.generateServerValues();
309309
const newNodeUnresolved = nodeFromJSON(newVal, newPriority);
310+
const existing = this.serverSyncTree_.calcCompleteEventCache(path);
310311
const newNode = resolveDeferredValueSnapshot(
311312
newNodeUnresolved,
313+
existing,
312314
serverValues
313315
);
314316

@@ -359,6 +361,7 @@ export class Repo {
359361
const newNodeUnresolved = nodeFromJSON(changedValue);
360362
changedChildren[changedKey] = resolveDeferredValueSnapshot(
361363
newNodeUnresolved,
364+
this.serverSyncTree_.calcCompleteEventCache(path),
362365
serverValues
363366
);
364367
});
@@ -413,6 +416,7 @@ export class Repo {
413416
const serverValues = this.generateServerValues();
414417
const resolvedOnDisconnectTree = resolveDeferredValueTree(
415418
this.onDisconnect_,
419+
this.serverSyncTree_,
416420
serverValues
417421
);
418422
let events: Event[] = [];

packages/database/src/core/Repo_transaction.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ Repo.prototype.startTransaction = function(
245245
const newNodeUnresolved = nodeFromJSON(newVal, priorityForNode);
246246
const newNode = resolveDeferredValueSnapshot(
247247
newNodeUnresolved,
248+
currentState,
248249
serverValues
249250
);
250251
transaction.currentOutputSnapshotRaw = newNodeUnresolved;
@@ -522,6 +523,7 @@ Repo.prototype.startTransaction = function(
522523
const serverValues = this.generateServerValues();
523524
const newNodeResolved = resolveDeferredValueSnapshot(
524525
newDataNode,
526+
currentNode,
525527
serverValues
526528
);
527529

packages/database/src/core/SyncTree.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -474,18 +474,17 @@ export class SyncTree {
474474
}
475475

476476
/**
477-
* Returns a complete cache, if we have one, of the data at a particular path. The location must have a listener above
478-
* it, but as this is only used by transaction code, that should always be the case anyways.
477+
* Returns a complete cache, if we have one, of the data at a particular path. If the location does not have a
478+
* listener above it, we will get a false "null". This shouldn't be a problem because transactions will always
479+
* have a listener above, and atomic operations would correctly show a jitter of <increment value> ->
480+
* <incremented total> as the write is applied locally and then acknowledged at the server.
479481
*
480482
* Note: this method will *include* hidden writes from transaction with applyLocally set to false.
481483
*
482484
* @param path The path to the data we want
483485
* @param writeIdsToExclude A specific set to be excluded
484486
*/
485-
calcCompleteEventCache(
486-
path: Path,
487-
writeIdsToExclude?: number[]
488-
): Node | null {
487+
calcCompleteEventCache(path: Path, writeIdsToExclude?: number[]): Node {
489488
const includeHiddenSets = true;
490489
const writeTree = this.pendingWriteTree_;
491490
const serverCache = this.syncPointTree_.findOnPath(path, function(

packages/database/src/core/util/ServerValues.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { nodeFromJSON } from '../snap/nodeFromJSON';
2323
import { PRIORITY_INDEX } from '../snap/indexes/PriorityIndex';
2424
import { Node } from '../snap/Node';
2525
import { ChildrenNode } from '../snap/ChildrenNode';
26+
import { SyncTree } from '../SyncTree';
2627

2728
/**
2829
* Generate placeholders for deferred values.
@@ -48,16 +49,64 @@ export const generateWithValues = function(
4849
*/
4950
export const resolveDeferredValue = function(
5051
value: { [k: string]: any } | string | number | boolean,
52+
existing: Node,
5153
serverValues: { [k: string]: any }
5254
): string | number | boolean {
5355
if (!value || typeof value !== 'object') {
5456
return value as string | number | boolean;
57+
}
58+
assert('.sv' in value, 'Unexpected leaf node or priority contents');
59+
60+
if (typeof value['.sv'] === 'string') {
61+
return resolveScalarDeferredValue(value['.sv'], existing, serverValues);
62+
} else if (typeof value['.sv'] === 'object') {
63+
return resolveComplexDeferredValue(value['.sv'], existing, serverValues);
5564
} else {
56-
assert('.sv' in value, 'Unexpected leaf node or priority contents');
57-
return serverValues[value['.sv']];
65+
assert(false, 'Unexpected server value: ' + JSON.stringify(value, null, 2));
66+
}
67+
};
68+
69+
const resolveScalarDeferredValue = function(
70+
op: string,
71+
existing: Node,
72+
serverValues: { [k: string]: any }
73+
): string | number | boolean {
74+
switch (op) {
75+
case 'timestamp':
76+
return serverValues['timestamp'];
77+
default:
78+
assert(false, 'Unexpected server value: ' + op);
5879
}
5980
};
6081

82+
const resolveComplexDeferredValue = function(
83+
op: Object,
84+
existing: Node,
85+
unused: { [k: string]: any }
86+
): string | number | boolean {
87+
if (!op.hasOwnProperty('increment')) {
88+
assert(false, 'Unexpected server value: ' + JSON.stringify(op, null, 2));
89+
}
90+
const delta = op['increment'];
91+
if (typeof delta !== 'number') {
92+
assert(false, 'Unexpected increment value: ' + delta);
93+
}
94+
95+
// Incrementing a non-number sets the value to the incremented amount
96+
if (!existing.isLeafNode()) {
97+
return delta;
98+
}
99+
100+
const leaf = existing as LeafNode;
101+
const existingVal = leaf.getValue();
102+
if (typeof existingVal !== 'number') {
103+
return delta;
104+
}
105+
106+
// No need to do over/underflow arithmetic here because JS only handles floats under the covers
107+
return existingVal + delta;
108+
};
109+
61110
/**
62111
* Recursively replace all deferred values and priorities in the tree with the
63112
* specified generated replacement values.
@@ -67,13 +116,19 @@ export const resolveDeferredValue = function(
67116
*/
68117
export const resolveDeferredValueTree = function(
69118
tree: SparseSnapshotTree,
119+
syncTree: SyncTree,
70120
serverValues: Object
71121
): SparseSnapshotTree {
72122
const resolvedTree = new SparseSnapshotTree();
73123
tree.forEachTree(new Path(''), function(path, node) {
124+
const existing = syncTree.calcCompleteEventCache(path);
125+
assert(
126+
existing !== null && typeof existing !== 'undefined',
127+
'Expected ChildrenNode.EMPTY_NODE for nulls'
128+
);
74129
resolvedTree.remember(
75130
path,
76-
resolveDeferredValueSnapshot(node, serverValues)
131+
resolveDeferredValueSnapshot(node, existing, serverValues)
77132
);
78133
});
79134
return resolvedTree;
@@ -89,6 +144,7 @@ export const resolveDeferredValueTree = function(
89144
*/
90145
export const resolveDeferredValueSnapshot = function(
91146
node: Node,
147+
existing: Node,
92148
serverValues: Object
93149
): Node {
94150
const rawPri = node.getPriority().val() as
@@ -97,12 +153,20 @@ export const resolveDeferredValueSnapshot = function(
97153
| null
98154
| number
99155
| string;
100-
const priority = resolveDeferredValue(rawPri, serverValues);
156+
const priority = resolveDeferredValue(
157+
rawPri,
158+
existing.getPriority(),
159+
serverValues
160+
);
101161
let newNode: Node;
102162

103163
if (node.isLeafNode()) {
104164
const leafNode = node as LeafNode;
105-
const value = resolveDeferredValue(leafNode.getValue(), serverValues);
165+
const value = resolveDeferredValue(
166+
leafNode.getValue(),
167+
existing,
168+
serverValues
169+
);
106170
if (
107171
value !== leafNode.getValue() ||
108172
priority !== leafNode.getPriority().val()
@@ -120,6 +184,7 @@ export const resolveDeferredValueSnapshot = function(
120184
childrenNode.forEachChild(PRIORITY_INDEX, function(childName, childNode) {
121185
const newChildNode = resolveDeferredValueSnapshot(
122186
childNode,
187+
existing.getImmediateChild(childName),
123188
serverValues
124189
);
125190
if (newChildNode !== childNode) {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* @license
3+
* Copyright 2019 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { expect } from 'chai';
19+
import { getRandomNode } from './helpers/util';
20+
import { Database } from '../src/api/Database';
21+
import { Reference } from '../src/api/Reference';
22+
import { nodeFromJSON } from '../src/core/snap/nodeFromJSON';
23+
24+
describe('ServerValue tests', () => {
25+
it('resolves timestamps locally', async () => {
26+
const node = getRandomNode() as Reference;
27+
const start = Date.now();
28+
const values: Array<number> = [];
29+
node.on('value', snap => {
30+
expect(typeof snap.val()).to.equal('number');
31+
values.push(snap.val() as number);
32+
});
33+
await node.set(Database.ServerValue.TIMESTAMP);
34+
node.off('value');
35+
36+
// By the time the write is acknowledged, we should have a local and
37+
// server version of the timestamp.
38+
expect(values.length).to.equal(2);
39+
values.forEach(serverTime => {
40+
const delta = Math.abs(serverTime - start);
41+
expect(delta).to.be.lessThan(1000);
42+
});
43+
});
44+
45+
it('handles increments without listeners', () => {
46+
// Ensure that increments don't explode when the SyncTree must return a null
47+
// node (i.e. ChildrenNode.EMPTY_NODE) because there is not yet any synced
48+
// data.
49+
const node = getRandomNode() as Reference;
50+
const addOne = Database.ServerValue._increment(1);
51+
52+
node.set(addOne);
53+
});
54+
55+
it('handles increments locally', async () => {
56+
const node = getRandomNode() as Reference;
57+
const addOne = Database.ServerValue._increment(1);
58+
59+
// Must go offline because the latest emulator may not support this server op
60+
// This also means we can't await node operations, which would block the test.
61+
node.database.goOffline();
62+
try {
63+
const values: Array<any> = [];
64+
const expected: Array<any> = [];
65+
node.on('value', snap => values.push(snap.val()));
66+
67+
// null -> increment(x) = x
68+
node.set(addOne);
69+
expected.push(1);
70+
71+
// x -> increment(y) = x + y
72+
node.set(5);
73+
node.set(addOne);
74+
expected.push(5);
75+
expected.push(6);
76+
77+
// str -> increment(x) = x
78+
node.set('hello');
79+
node.set(addOne);
80+
expected.push('hello');
81+
expected.push(1);
82+
83+
// obj -> increment(x) = x
84+
node.set({ 'hello': 'world' });
85+
node.set(addOne);
86+
expected.push({ 'hello': 'world' });
87+
expected.push(1);
88+
89+
node.off('value');
90+
expect(values).to.deep.equal(expected);
91+
} finally {
92+
node.database.goOnline();
93+
}
94+
});
95+
});

0 commit comments

Comments
 (0)