Skip to content

Commit

Permalink
feat: support update throttling
Browse files Browse the repository at this point in the history
  • Loading branch information
ccollie committed Aug 2, 2021
1 parent 3eca298 commit 929e67d
Show file tree
Hide file tree
Showing 7 changed files with 394 additions and 4 deletions.
6 changes: 5 additions & 1 deletion packages/graphql-live-query/src/GraphQLLiveDirective.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GraphQLDirective, DirectiveLocation, GraphQLBoolean } from "graphql";
import { GraphQLDirective, DirectiveLocation, GraphQLBoolean, GraphQLInt } from "graphql";

export const GraphQLLiveDirective = new GraphQLDirective({
name: "live",
Expand All @@ -11,5 +11,9 @@ export const GraphQLLiveDirective = new GraphQLDirective({
defaultValue: true,
description: "Whether the query should be live or not.",
},
throttle: {
type: GraphQLInt,
description: "Limit updates to at most once per \"throttle\" milliseconds."
}
},
});
131 changes: 131 additions & 0 deletions packages/graphql-live-query/src/getLiveQueryOperationThrottle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { parse, getOperationAST } from "graphql";
import { getLiveQueryOperationThrottle } from "./getLiveQueryOperationThrottle";

test("operation without @live returns undefined", () => {
const node = getOperationAST(
parse(/* GraphQL */ `
query foo {
foo
}
`)
)!;

expect(getLiveQueryOperationThrottle(node)).toBe(undefined);
});

test("operation with @live but no throttle arg returns undefined", () => {
const node = getOperationAST(
parse(/* GraphQL */ `
query foo @live {
foo
}
`)
)!;

expect(getLiveQueryOperationThrottle(node)).toBe(undefined);
});

test("operation with @live and 'if' argument set to 'false' returns undefined", () => {
const node = getOperationAST(
parse(/* GraphQL */ `
query foo @live(if: false, throttle: 1000) {
foo
}
`)
)!;

expect(getLiveQueryOperationThrottle(node)).toBe(undefined);
});

test("operation with @live and 'throttle' set to an int returns the value", () => {
const node = getOperationAST(
parse(/* GraphQL */ `
query foo @live(throttle: 18395) {
foo
}
`)
)!;

expect(getLiveQueryOperationThrottle(node)).toBe(18395);
});

test("operation with @live and 'throttle' expects its value to be an int", () => {
const node = getOperationAST(
parse(/* GraphQL */ `
query foo @live(throttle: "12345") {
foo
}
`)
)!;

expect(() => getLiveQueryOperationThrottle(node)).toThrow();
});

test("operation with @live and 'throttle' range checks its value", () => {
const MAX_INT = 2147483647;
const MIN_INT = -2147483648;

let node = getOperationAST(
parse(/* GraphQL */ `
query foo @live(throttle: ${MIN_INT - 1}) {
foo
}
`)
)!;

expect(() => getLiveQueryOperationThrottle(node)).toThrow();

node = getOperationAST(
parse(/* GraphQL */ `
query foo @live(throttle: ${MAX_INT + 1}) {
foo
}
`)
)!;

expect(() => getLiveQueryOperationThrottle(node)).toThrow();
});

test("operation with @live and 'throttle' argument set to a variable returns the value", () => {
const node = getOperationAST(
parse(/* GraphQL */ `
query foo($throttle: Int!) @live(throttle: $throttle) {
foo
}
`)
)!;

expect(getLiveQueryOperationThrottle(node, { throttle: 12345 })).toBe(12345);
});

test("operation with @live and 'throttle' argument set to variable rejects non-int values", () => {
let node = getOperationAST(
parse(/* GraphQL */ `
query foo($bool: Boolean = false) @live(throttle: $bool) {
foo
}
`)
)!;

expect(() => getLiveQueryOperationThrottle(node, { bool: true })).toThrow();

node = getOperationAST(
parse(/* GraphQL */ `
query foo($str: String!) @live(throttle: $str) {
foo
}
`)
)!;

expect(() => getLiveQueryOperationThrottle(node, { str: "invalid" })).toThrow();

node = getOperationAST(
parse(/* GraphQL */ `
query foo($float: Float!) @live(throttle: $float) {
foo
}
`)
)!;

expect(() => getLiveQueryOperationThrottle(node, { float: 14.5 })).toThrow();
});
78 changes: 78 additions & 0 deletions packages/graphql-live-query/src/getLiveQueryOperationThrottle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { DefinitionNode } from "graphql";
import { isSome, isNone, Maybe } from "./Maybe";
import { GraphQLError, print } from "graphql";
import { isLiveQueryOperationDefinitionNode } from "./isLiveQueryOperationDefinitionNode";

// As per the GraphQL Spec, Integers are only treated as valid when a valid
// 32-bit signed integer, providing the broadest support across platforms.
//
// n.b. JavaScript's integers are safe between -(2^53 - 1) and 2^53 - 1 because
// they are internally represented as IEEE 754 doubles.
const MAX_INT = 2147483647;
const MIN_INT = -2147483648;

function checkRange(name: string, value: number): number {
if (value > MAX_INT || value < MIN_INT) {
throw new GraphQLError(
`${name} value cannot represent a non 32-bit signed integer value: ${value}`,
);
}
return value;
}

function coerceVariable(name: string, inputValue: unknown): number {
if (typeof inputValue !== 'number' || !Number.isInteger(inputValue)) {
throw new GraphQLError(
`${name} value is not an integer : ${inputValue}`,
);
}
return checkRange(name, inputValue);
}

export const getLiveQueryOperationThrottle = (
input: DefinitionNode,
variables?: Maybe<{ [key: string]: unknown }>
): Maybe<number> => {
if (!isLiveQueryOperationDefinitionNode(input)) {
return undefined;
}
const liveDirective = input.directives?.find((d) => d.name.value === "live");
if (isNone(liveDirective)) {
return undefined;
}
const throttleArgument = liveDirective.arguments?.find(
(arg) => arg.name.value === "throttle"
);
if (isNone(throttleArgument)) {
return undefined;
}
const valueNode = throttleArgument.value;
if (valueNode.kind === "IntValue") {
return checkRange('throttle', parseInt(valueNode.value, 10));
}
if (valueNode.kind !== "Variable") {
throw new GraphQLError(
`Throttle is not an int or a variable: ${print(valueNode)}`,
valueNode,
);
}

const variableName = valueNode.name.value;

if (isSome(variables) && isSome(variables[variableName])) {
return coerceVariable(variableName, variables[variableName]);
}

const variableNode = input.variableDefinitions?.find(
(def) => def.variable.name.value === variableName
);

if (variableNode?.defaultValue?.kind === "IntValue") {
return coerceVariable(variableName, variableNode.defaultValue.value);
}

throw new GraphQLError(
`throttle cannot represent non-integer values: ${print(valueNode)}`,
valueNode,
);
};
1 change: 1 addition & 0 deletions packages/graphql-live-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./GraphQLLiveDirective";
export * from "./isLiveQueryOperationDefinitionNode";
export * from "./rules/NoLiveMixedWithDeferStreamRule";
export * from "./LiveExecutionResult";
export * from "./getLiveQueryOperationThrottle";
22 changes: 19 additions & 3 deletions packages/in-memory-live-query-store/src/InMemoryLiveQueryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import {
isAsyncIterable,
} from "@n1ru4l/push-pull-async-iterable-iterator";
import {
getLiveQueryOperationThrottle,
isLiveQueryOperationDefinitionNode,
LiveExecutionResult,
LiveExecutionResult
} from "@n1ru4l/graphql-live-query";
import { extractLiveQueryRootFieldCoordinates } from "./extractLiveQueryRootFieldCoordinates";
import { isNonNullIDScalarType } from "./isNonNullIDScalarType";
import { runWith } from "./runWith";
import { isNone, None } from "./Maybe";
import { ResourceTracker } from "./ResourceTracker";
import { throttle } from "./throttle";

type PromiseOrValue<T> = T | Promise<T>;
type StoreRecord = {
Expand Down Expand Up @@ -259,14 +261,27 @@ export class InMemoryLiveQueryStore {
const { asyncIterableIterator: iterator, pushValue } =
makePushPullAsyncIterableIterator<LiveExecutionResult>();

// utils for throttle
let cleanup = () => {};

const throttleIfNeeded = (fn: StoreRecord['run']) => {
const throttleWait = getLiveQueryOperationThrottle(operationNode, variableValues);
if (!isNone(throttleWait) && throttleWait > 0) {
const { run, cancel } = throttle(() => fn(), throttleWait);
cleanup = cancel;
return run;
}
return fn;
}

// keep track that current execution is the latest in order to prevent race-conditions :)
let executionCounter = 0;
let previousIdentifier = new Set<string>(rootFieldIdentifier);

const record: StoreRecord = {
iterator,
pushValue,
run: () => {
run: throttleIfNeeded(() => {
executionCounter = executionCounter + 1;
const counter = executionCounter;
const newIdentifier = new Set(rootFieldIdentifier);
Expand Down Expand Up @@ -351,7 +366,7 @@ export class InMemoryLiveQueryStore {
record.pushValue(liveResult);
}
});
},
}),
};

this._resourceTracker.register(record, previousIdentifier);
Expand All @@ -361,6 +376,7 @@ export class InMemoryLiveQueryStore {
// TODO: figure out how we can do this stuff without monkey-patching the iterator
const originalReturn = iterator.return!.bind(iterator);
iterator.return = () => {
cleanup();
this._resourceTracker.release(record, previousIdentifier);
return originalReturn();
};
Expand Down

0 comments on commit 929e67d

Please sign in to comment.