Make TimestampMs validation optional (#1789)

Currently, the Timestamp module requires that all TimestampMs types be
validated via the `fromNumber` method. However, as discussed in #1788,
this creates marginal benefit (just knowing that a TimestampMs is an
integer does not give great confidence that it's a meaningful and
correct timestamp for any use case), and creates a lot of overhead,
especially in test code. It makes adopting the TimestampMs type across
the codebase an arduous and mostly thankless task that would involve
re-writing dozens or hundreds of test instances to add zero actual
safety.

Therefore, I've made the type non-opaque, and renamed `fromNumber` to
`validate`. I then changed existing test code to not use the validator,
since we don't need any manual validation of our test cases, and `123`
is not a meaningful timestamp in any case :)

Test plan: `yarn test`
This commit is contained in:
Dandelion Mané 2020-05-13 11:44:23 -07:00 committed by GitHub
parent 96c34e3c6c
commit 34bb471d38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 36 additions and 44 deletions

View File

@ -9,7 +9,6 @@ import {NodeAddress} from "../core/graph";
import type {Alias} from "../plugins/identity/alias"; import type {Alias} from "../plugins/identity/alias";
import type {PluginDeclaration} from "./pluginDeclaration"; import type {PluginDeclaration} from "./pluginDeclaration";
import type {TimestampMs} from "../util/timestamp"; import type {TimestampMs} from "../util/timestamp";
import * as Timestamp from "../util/timestamp";
import {TimelineCred} from "./timeline/timelineCred"; import {TimelineCred} from "./timeline/timelineCred";
import {nodeWeightEvaluator} from "../core/algorithm/weightEvaluator"; import {nodeWeightEvaluator} from "../core/algorithm/weightEvaluator";
@ -66,14 +65,12 @@ export function fromTimelineCredAndPlugins(
// know what period to mint it in. // know what period to mint it in.
// When we transition to CredRank, we should remove this check. // When we transition to CredRank, we should remove this check.
const minted = timestampMs == null ? 0 : nodeEvaluator(address); const minted = timestampMs == null ? 0 : nodeEvaluator(address);
const timestamp =
timestampMs == null ? null : Timestamp.fromNumber(timestampMs);
return { return {
address: NodeAddress.toParts(address), address: NodeAddress.toParts(address),
cred, cred,
minted, minted,
description, description,
timestamp, timestamp: timestampMs,
}; };
} }
); );

View File

@ -7,7 +7,6 @@ import {
NodeAddress, NodeAddress,
} from "../../core/graph"; } from "../../core/graph";
import * as Weights from "../../core/weights"; import * as Weights from "../../core/weights";
import * as Timestamp from "../../util/timestamp";
import sortBy from "../../util/sortBy"; import sortBy from "../../util/sortBy";
import type {ReferenceDetector, URL} from "../../core/references"; import type {ReferenceDetector, URL} from "../../core/references";
import {createWeightedGraph, initiativeWeight} from "./createGraph"; import {createWeightedGraph, initiativeWeight} from "./createGraph";
@ -30,7 +29,7 @@ import {
const exampleEntry = (overrides: $Shape<NodeEntry>): NodeEntry => ({ const exampleEntry = (overrides: $Shape<NodeEntry>): NodeEntry => ({
key: overrides.title ? _titleSlug(overrides.title) : "sample-title", key: overrides.title ? _titleSlug(overrides.title) : "sample-title",
title: "Sample title", title: "Sample title",
timestampMs: Timestamp.fromNumber(123), timestampMs: 123,
contributors: [], contributors: [],
weight: 456, weight: 456,
...overrides, ...overrides,
@ -40,7 +39,7 @@ function _createInitiative(overrides?: $Shape<Initiative>): Initiative {
return { return {
id: createId("UNSET_SUBTYPE", "42"), id: createId("UNSET_SUBTYPE", "42"),
title: "Unset test initiative", title: "Unset test initiative",
timestampMs: Timestamp.fromNumber(123), timestampMs: 123,
completed: false, completed: false,
contributions: {urls: [], entries: []}, contributions: {urls: [], entries: []},
dependencies: {urls: [], entries: []}, dependencies: {urls: [], entries: []},
@ -66,7 +65,7 @@ class MockInitiativeRepository implements InitiativeRepository {
const initiative = _createInitiative({ const initiative = _createInitiative({
id: createId("TEST_SUBTYPE", String(num)), id: createId("TEST_SUBTYPE", String(num)),
title: `Example Initiative ${num}`, title: `Example Initiative ${num}`,
timestampMs: Timestamp.fromNumber(400 + num), timestampMs: 400 + num,
...shape, ...shape,
}); });

View File

@ -1,7 +1,6 @@
// @flow // @flow
import {type TimestampMs} from "../../util/timestamp"; import {type TimestampMs} from "../../util/timestamp";
import * as Timestamp from "../../util/timestamp";
import {type NodeEntry} from "./nodeEntry"; import {type NodeEntry} from "./nodeEntry";
import { import {
type EdgeSpec, type EdgeSpec,
@ -14,7 +13,7 @@ import {
const exampleEntry = (overrides: $Shape<NodeEntry>): NodeEntry => ({ const exampleEntry = (overrides: $Shape<NodeEntry>): NodeEntry => ({
key: "sample-title", key: "sample-title",
title: "Sample title", title: "Sample title",
timestampMs: Timestamp.fromNumber(123), timestampMs: 123,
contributors: [], contributors: [],
weight: 456, weight: 456,
...overrides, ...overrides,
@ -23,7 +22,7 @@ const exampleEntry = (overrides: $Shape<NodeEntry>): NodeEntry => ({
describe("plugins/initiatives/edgeSpec", () => { describe("plugins/initiatives/edgeSpec", () => {
describe("normalizeEdgeSpec", () => { describe("normalizeEdgeSpec", () => {
it("should add empty arrays when missing", () => { it("should add empty arrays when missing", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(321); const timestampMs: TimestampMs = 321;
const json: EdgeSpecJson = {}; const json: EdgeSpecJson = {};
const actual = normalizeEdgeSpec(json, timestampMs); const actual = normalizeEdgeSpec(json, timestampMs);
@ -31,7 +30,7 @@ describe("plugins/initiatives/edgeSpec", () => {
}); });
it("should preserve urls when present", () => { it("should preserve urls when present", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(321); const timestampMs: TimestampMs = 321;
const json: EdgeSpecJson = { const json: EdgeSpecJson = {
urls: ["https://foo.bar/a", "https://foo.bar/b"], urls: ["https://foo.bar/a", "https://foo.bar/b"],
}; };
@ -41,7 +40,7 @@ describe("plugins/initiatives/edgeSpec", () => {
}); });
it("should normalize any entries from json format", () => { it("should normalize any entries from json format", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(321); const timestampMs: TimestampMs = 321;
const json: EdgeSpecJson = { const json: EdgeSpecJson = {
entries: [{title: "Minimal example"}], entries: [{title: "Minimal example"}],
}; };
@ -62,7 +61,7 @@ describe("plugins/initiatives/edgeSpec", () => {
}); });
it("should throw on entries with the same key", () => { it("should throw on entries with the same key", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(321); const timestampMs: TimestampMs = 321;
const json: EdgeSpecJson = { const json: EdgeSpecJson = {
entries: [ entries: [
{key: "1", title: "first"}, {key: "1", title: "first"},

View File

@ -52,14 +52,14 @@ describe("plugins/initiatives/nodeEntry", () => {
describe("normalizeNodeEntry", () => { describe("normalizeNodeEntry", () => {
it("should throw without a title", () => { it("should throw without a title", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(123); const timestampMs: TimestampMs = 123;
const entry: NodeEntryJson = {key: "no-title"}; const entry: NodeEntryJson = {key: "no-title"};
const f = () => normalizeNodeEntry(entry, timestampMs); const f = () => normalizeNodeEntry(entry, timestampMs);
expect(f).toThrow(TypeError); expect(f).toThrow(TypeError);
}); });
it("should handle a minimal entry", () => { it("should handle a minimal entry", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(123); const timestampMs = 123;
const entry: NodeEntryJson = {title: "Most minimal"}; const entry: NodeEntryJson = {title: "Most minimal"};
const expected: NodeEntry = { const expected: NodeEntry = {
title: "Most minimal", title: "Most minimal",
@ -72,7 +72,7 @@ describe("plugins/initiatives/nodeEntry", () => {
}); });
it("should handle an entry with weights", () => { it("should handle an entry with weights", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(123); const timestampMs: TimestampMs = 123;
const entry: NodeEntryJson = {title: "Include weight", weight: 42}; const entry: NodeEntryJson = {title: "Include weight", weight: 42};
const expected: NodeEntry = { const expected: NodeEntry = {
title: "Include weight", title: "Include weight",
@ -85,7 +85,7 @@ describe("plugins/initiatives/nodeEntry", () => {
}); });
it("should handle an entry with contributors", () => { it("should handle an entry with contributors", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(123); const timestampMs: TimestampMs = 123;
const entry: NodeEntryJson = { const entry: NodeEntryJson = {
title: "Include contributors", title: "Include contributors",
contributors: ["https://foo.bar/u/abc"], contributors: ["https://foo.bar/u/abc"],
@ -101,7 +101,7 @@ describe("plugins/initiatives/nodeEntry", () => {
}); });
it("should handle an entry with timestamp", () => { it("should handle an entry with timestamp", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(123); const timestampMs: TimestampMs = 123;
const entry: NodeEntryJson = { const entry: NodeEntryJson = {
title: "Include timestamp", title: "Include timestamp",
timestampIso: Timestamp.toISO(Date.parse("2018-02-03T12:34:56.789Z")), timestampIso: Timestamp.toISO(Date.parse("2018-02-03T12:34:56.789Z")),
@ -110,16 +110,14 @@ describe("plugins/initiatives/nodeEntry", () => {
title: "Include timestamp", title: "Include timestamp",
key: "include-timestamp", key: "include-timestamp",
contributors: [], contributors: [],
timestampMs: Timestamp.fromNumber( timestampMs: Date.parse("2018-02-03T12:34:56.789Z"),
Date.parse("2018-02-03T12:34:56.789Z")
),
weight: null, weight: null,
}; };
expect(normalizeNodeEntry(entry, timestampMs)).toEqual(expected); expect(normalizeNodeEntry(entry, timestampMs)).toEqual(expected);
}); });
it("should handle an entry with key", () => { it("should handle an entry with key", () => {
const timestampMs: TimestampMs = Timestamp.fromNumber(123); const timestampMs: TimestampMs = 123;
const entry: NodeEntryJson = { const entry: NodeEntryJson = {
title: "Include key", title: "Include key",
key: "much-different-key", key: "much-different-key",

View File

@ -9,7 +9,7 @@
*/ */
// A timestamp representation in ms since epoch. // A timestamp representation in ms since epoch.
export opaque type TimestampMs: number = number; export type TimestampMs = number;
// A timestamp representation in ISO 8601 format. // A timestamp representation in ISO 8601 format.
export opaque type TimestampISO: string = string; export opaque type TimestampISO: string = string;
@ -22,7 +22,7 @@ export opaque type TimestampISO: string = string;
* than a forced refactor across the codebase. * than a forced refactor across the codebase.
*/ */
export function toISO(timestampLike: TimestampMs | number): TimestampISO { export function toISO(timestampLike: TimestampMs | number): TimestampISO {
const timestampMs: TimestampMs = fromNumber(timestampLike); const timestampMs: TimestampMs = validate(timestampLike);
return new Date(timestampMs).toISOString(); return new Date(timestampMs).toISOString();
} }
@ -47,13 +47,12 @@ export function fromISO(timestampISO: TimestampISO): TimestampMs {
} }
/** /**
* Creates a TimestampMs from a number input. * Validate that a number is potentially a valid timestamp.
* *
* Since much of the previous types have used `number` as a type instead of * This checks that the number is a finite integer, which avoids some potential
* TimestampMs. Accepting `number` will give an easier upgrade path, rather * numbers that are not valid timestamps.
* than a forced refactor across the codebase.
*/ */
export function fromNumber(timestampMs: number): TimestampMs { export function validate(timestampMs: number): TimestampMs {
const asNumber = Number(timestampMs); const asNumber = Number(timestampMs);
if ( if (
timestampMs === null || timestampMs === null ||

View File

@ -1,7 +1,7 @@
// @flow // @flow
import type {TimestampMs, TimestampISO} from "./timestamp"; import type {TimestampMs, TimestampISO} from "./timestamp";
import {fromISO, toISO, fromNumber} from "./timestamp"; import {fromISO, toISO, validate} from "./timestamp";
/** /**
* Helper function to write readable "toThrow" tests. This intentionally * Helper function to write readable "toThrow" tests. This intentionally
@ -33,14 +33,14 @@ describe("util/timestamp", () => {
describe("roundtrips", () => { describe("roundtrips", () => {
it("should handle number roundtrips", () => { it("should handle number roundtrips", () => {
const origin: number = fullExample.ms; const origin: number = fullExample.ms;
expect(fromNumber(origin)).toBe(origin); expect(validate(origin)).toBe(origin);
expect(fromISO(toISO(origin))).toBe(origin); expect(fromISO(toISO(origin))).toBe(origin);
expect(fromNumber(fromISO(toISO(origin)))).toBe(origin); expect(validate(fromISO(toISO(origin)))).toBe(origin);
}); });
it("should handle ISO roundtrips", () => { it("should handle ISO roundtrips", () => {
const origin: TimestampISO = fullExample.ISO; const origin: TimestampISO = fullExample.ISO;
expect(toISO(fromISO(origin))).toBe(origin); expect(toISO(fromISO(origin))).toBe(origin);
expect(toISO(fromNumber(fromISO(origin)))).toBe(origin); expect(toISO(validate(fromISO(origin)))).toBe(origin);
}); });
}); });
@ -96,34 +96,34 @@ describe("util/timestamp", () => {
}); });
}); });
describe("fromNumber", () => { describe("validate", () => {
it("should throw on null", () => { it("should throw on null", () => {
given(null).expect(fromNumber).toThrow(TypeError); given(null).expect(validate).toThrow(TypeError);
}); });
it("should throw on undefined", () => { it("should throw on undefined", () => {
given(undefined).expect(fromNumber).toThrow(TypeError); given(undefined).expect(validate).toThrow(TypeError);
}); });
it("should throw on NaN", () => { it("should throw on NaN", () => {
given(NaN).expect(fromNumber).toThrow(TypeError); given(NaN).expect(validate).toThrow(TypeError);
}); });
it("should throw on Infinity", () => { it("should throw on Infinity", () => {
given(Infinity).expect(fromNumber).toThrow(TypeError); given(Infinity).expect(validate).toThrow(TypeError);
}); });
it("should throw on floating point numbers", () => { it("should throw on floating point numbers", () => {
given(1 / 3) given(1 / 3)
.expect(fromNumber) .expect(validate)
.toThrow(TypeError); .toThrow(TypeError);
}); });
it("should handle 0 correctly", () => { it("should handle 0 correctly", () => {
expect(fromNumber(0)).toBe(0); expect(validate(0)).toBe(0);
}); });
it("should handle Date.now correctly", () => { it("should handle Date.now correctly", () => {
const now = Date.now(); const now = Date.now();
expect(fromNumber(now)).toBe(now); expect(validate(now)).toBe(now);
}); });
it("should handle examples correctly", () => { it("should handle examples correctly", () => {
expect(fromNumber(fullExample.ms)).toBe(fullExample.ms); expect(validate(fullExample.ms)).toBe(fullExample.ms);
expect(fromNumber(partialExample.ms)).toBe(partialExample.ms); expect(validate(partialExample.ms)).toBe(partialExample.ms);
}); });
}); });
}); });