@urql/exchange-graphcache
Advanced tools
Comparing version 0.1.0-alpha.5 to 1.0.0-beta.0
@@ -1,22 +0,3 @@ | ||
export * from './types'; | ||
export { getFieldArguments, normalizeVariables } from './variables'; | ||
export * from './traversal'; | ||
export * from './node'; | ||
import { DocumentNode, FieldNode, FragmentDefinitionNode, InlineFragmentNode, OperationDefinitionNode, SelectionNode } from 'graphql'; | ||
import { Fragments, VarsMap } from './types'; | ||
/** Checks whether a SelectionNode is a FieldNode */ | ||
export declare const isFieldNode: (node: SelectionNode) => node is FieldNode; | ||
/** Checks whether a SelectionNode is an InlineFragmentNode */ | ||
export declare const isInlineFragment: (node: SelectionNode) => node is InlineFragmentNode; | ||
/** Returns the main operation's definition */ | ||
export declare const getMainOperation: (doc: DocumentNode) => void | OperationDefinitionNode; | ||
/** Returns the first fragment definition */ | ||
export declare const getMainFragment: (doc: DocumentNode) => void | FragmentDefinitionNode; | ||
/** Returns a normalized form of variables with defaulted values */ | ||
export declare const getNormalizedVars: (node: OperationDefinitionNode, input?: object | null | undefined) => VarsMap; | ||
/** Returns a mapping from fragment names to their selections */ | ||
export declare const getFragments: (doc: DocumentNode) => Fragments; | ||
/** Returns either the field's name or the field's alias */ | ||
export declare const getFieldAlias: (node: FieldNode) => string; | ||
/** Evaluates a fields arguments taking vars into account */ | ||
export declare const getFieldArguments: (node: FieldNode, vars: VarsMap) => VarsMap | null; | ||
/** Checks whether a given SelectionNode should be ignored based on @skip or @include directives */ | ||
export declare const shouldInclude: (node: SelectionNode, vars: VarsMap) => boolean; |
@@ -1,3 +0,2 @@ | ||
import { DefinitionNode, FragmentDefinitionNode, NamedTypeNode, NameNode, OperationDefinitionNode, SelectionSetNode } from 'graphql'; | ||
import { OperationType } from './types'; | ||
import { NamedTypeNode, NameNode, SelectionNode, SelectionSetNode, InlineFragmentNode, FieldNode, OperationDefinitionNode, FragmentDefinitionNode } from 'graphql'; | ||
/** Returns the name of a given node */ | ||
@@ -7,11 +6,14 @@ export declare const getName: (node: { | ||
}) => string; | ||
export declare const getOperationName: (node: OperationDefinitionNode) => "Query" | "Mutation" | "Subscription"; | ||
export declare const getFragmentTypeName: (node: FragmentDefinitionNode) => string; | ||
/** Returns either the field's name or the field's alias */ | ||
export declare const getFieldAlias: (node: FieldNode) => string; | ||
/** Returns the SelectionSet for a given inline or defined fragment node */ | ||
export declare const getSelectionSet: (node: { | ||
selectionSet?: SelectionSetNode | undefined; | ||
}) => ReadonlyArray<import("graphql").SelectionNode>; | ||
}) => readonly SelectionNode[]; | ||
export declare const getTypeCondition: ({ typeCondition, }: { | ||
typeCondition?: NamedTypeNode | undefined; | ||
}) => string | null; | ||
export declare const isOperationNode: (node: DefinitionNode) => node is OperationDefinitionNode; | ||
export declare const isFragmentNode: (node: DefinitionNode) => node is FragmentDefinitionNode; | ||
export declare const getOperationType: (node: OperationDefinitionNode) => OperationType; | ||
export declare const isFieldNode: (node: SelectionNode) => node is FieldNode; | ||
export declare const isInlineFragment: (node: SelectionNode) => node is InlineFragmentNode; |
import { Exchange } from 'urql'; | ||
import { SerializedStore } from './store'; | ||
import { UpdatesConfig, ResolverConfig, OptimisticMutationConfig } from './types'; | ||
export interface CacheExchangeOpts { | ||
initial?: SerializedStore; | ||
updates?: UpdatesConfig; | ||
resolvers?: ResolverConfig; | ||
optimistic?: OptimisticMutationConfig; | ||
} | ||
export declare const cacheExchange: (opts: CacheExchangeOpts) => Exchange; |
export * from './keys'; |
@@ -1,6 +0,5 @@ | ||
import { VarsMap } from '../ast'; | ||
import { SystemFields } from '../types'; | ||
import { Variables, SystemFields } from '../types'; | ||
export declare const isOperation: (typeName: string) => boolean; | ||
export declare const keyOfEntity: (entity: SystemFields) => string | null; | ||
export declare const keyOfField: (fieldName: string, args: VarsMap | null) => string; | ||
export declare const keyOfField: (fieldName: string, args?: Variables | null | undefined) => string; | ||
export declare const joinKeys: (parentKey: string, key: string) => string; |
export * from './operations'; | ||
export * from './types'; | ||
export { create, serialize } from './store'; | ||
export { Store } from './store'; | ||
export { cacheExchange } from './exchange'; |
export { query } from './query'; | ||
export { write } from './write'; | ||
export { gc } from './gc'; | ||
export { write, writeOptimistic, writeFragment } from './write'; |
@@ -0,4 +1,9 @@ | ||
import { Data, Completeness, OperationRequest } from '../types'; | ||
import { Store } from '../store'; | ||
import { Request, Result } from './types'; | ||
export interface QueryResult { | ||
completeness: Completeness; | ||
dependencies: Set<string>; | ||
data: null | Data; | ||
} | ||
/** Reads a request entirely from the store */ | ||
export declare const query: (store: Store, request: Request) => Result; | ||
export declare const query: (store: Store, request: OperationRequest) => QueryResult; |
import { FieldNode } from 'graphql'; | ||
import { Fragments, Variables } from '../types'; | ||
import { Store } from '../store'; | ||
import { Context, Request } from './types'; | ||
export declare const makeContext: (store: Store, request: Request) => void | Context; | ||
export declare const forEachFieldNode: (ctx: Context, select: ReadonlyArray<import("graphql").SelectionNode>, cb: (node: FieldNode) => void) => void; | ||
interface Context { | ||
store: Store; | ||
variables: Variables; | ||
fragments: Fragments; | ||
} | ||
export declare const forEachFieldNode: (typename: string, entityKey: string, select: readonly import("graphql").SelectionNode[], ctx: Context, cb: (node: FieldNode) => void) => void; | ||
export {}; |
@@ -0,7 +1,10 @@ | ||
import { Data, OperationRequest } from '../types'; | ||
import { Store } from '../store'; | ||
import { Data, Request, Result } from './types'; | ||
import { DocumentNode } from 'graphql'; | ||
export interface WriteResult { | ||
touched: string[]; | ||
dependencies: Set<string>; | ||
} | ||
/** Writes a request given its response to the store */ | ||
export declare const write: (store: Store, request: Request, data: Data) => Result; | ||
export declare const write: (store: Store, request: OperationRequest, data: Data) => WriteResult; | ||
export declare const writeOptimistic: (store: Store, request: OperationRequest, optimisticKey: number) => WriteResult; | ||
export declare const writeFragment: (store: Store, query: DocumentNode, data: Data) => void; |
@@ -1,23 +0,29 @@ | ||
import { Entity, Link, Links, Records } from '../types'; | ||
export interface Store { | ||
records: Records; | ||
links: Links; | ||
import { DocumentNode } from 'graphql'; | ||
import * as Pessimism from 'pessimism'; | ||
import { EntityField, Link, ResolverConfig, DataField, SystemFields, Variables, Data, UpdatesConfig, OptimisticMutationConfig } from '../types'; | ||
export declare const initStoreState: (optimisticKey: number | null) => void; | ||
export declare const clearStoreState: () => void; | ||
export declare const getCurrentDependencies: () => Set<string>; | ||
export declare const addDependency: (dependency: string) => void; | ||
export declare class Store { | ||
records: Pessimism.Map<EntityField>; | ||
links: Pessimism.Map<Link>; | ||
resolvers: ResolverConfig; | ||
updates: UpdatesConfig; | ||
optimisticMutations: OptimisticMutationConfig; | ||
constructor(resolvers?: ResolverConfig, updates?: UpdatesConfig, optimisticMutations?: OptimisticMutationConfig); | ||
clearOptimistic(optimisticKey: number): void; | ||
getRecord(fieldKey: string): EntityField; | ||
removeRecord(fieldKey: string): Pessimism.Map<EntityField>; | ||
writeRecord(field: EntityField, fieldKey: string): Pessimism.Map<EntityField>; | ||
getField(entityKey: string, fieldName: string, args?: Variables): EntityField; | ||
writeField(field: EntityField, entityKey: string, fieldName: string, args?: Variables): Pessimism.Map<EntityField>; | ||
getLink(key: string): undefined | Link; | ||
removeLink(key: string): Pessimism.Map<Link<string>>; | ||
writeLink(link: Link, key: string): Pessimism.Map<Link<string>>; | ||
resolveValueOrLink(fieldKey: string): DataField; | ||
resolve(entity: SystemFields, field: string, args?: Variables): DataField; | ||
hasField(key: string): boolean; | ||
updateQuery(dataQuery: DocumentNode, updater: (data: Data | null) => Data): void; | ||
writeFragment(dataFragment: DocumentNode, data: Data): void; | ||
} | ||
export interface SerializedStore { | ||
records: { | ||
[link: string]: Entity; | ||
}; | ||
links: { | ||
[link: string]: Link; | ||
}; | ||
} | ||
/** Creates a new Store with an optional initial, serialized state */ | ||
export declare const create: (initial?: SerializedStore | undefined) => Store; | ||
/** Serializes a given Store to a plain JSON structure */ | ||
export declare const serialize: (store: Store) => SerializedStore; | ||
export declare const find: (store: Store, key: string) => Entity | null; | ||
export declare const findOrCreate: (store: Store, key: string) => Entity; | ||
export declare const remove: (store: Store, key: string) => void; | ||
export declare const setLink: (store: Store, key: string, link: Link) => void; | ||
export declare const removeLink: (store: Store, key: string) => void; | ||
export declare const readLink: (store: Store, key: string) => string | void | (string | null)[] | null; |
@@ -1,6 +0,14 @@ | ||
/** A scalar is any fieldValue without a type. It can also include lists of scalars and embedded objects, which are simply represented as empty object type. */ | ||
export declare type Scalar = {} | string | number | null; | ||
export declare type NullPrototype = { | ||
[K in keyof ObjectConstructor]: never; | ||
}; | ||
import { DocumentNode, FragmentDefinitionNode, SelectionNode } from 'graphql'; | ||
import { Store } from './store'; | ||
export declare type NullArray<T> = Array<null | T>; | ||
export declare type SelectionSet = ReadonlyArray<SelectionNode>; | ||
export interface Fragments { | ||
[fragmentName: string]: void | FragmentDefinitionNode; | ||
} | ||
export declare type Primitive = null | number | boolean | string; | ||
export interface ScalarObject { | ||
__typename?: never; | ||
[key: string]: any; | ||
} | ||
export declare type Scalar = Primitive | ScalarObject; | ||
export interface SystemFields { | ||
@@ -11,13 +19,35 @@ __typename: string; | ||
} | ||
export interface EntityFields { | ||
[fieldName: string]: Scalar; | ||
export declare type EntityField = undefined | Scalar | Scalar[]; | ||
export declare type DataField = Scalar | Scalar[] | Data | NullArray<Data>; | ||
export interface DataFields { | ||
[fieldName: string]: DataField; | ||
} | ||
/** Every Entity must have a typeName. It might have some ID fields of which `id` and `_id` are recognised by default. Every other fieldValue is a scalar. */ | ||
export declare type Entity = NullPrototype & SystemFields & EntityFields; | ||
/** A link is a key or array of keys referencing other entities in the Records Map. It may be or contain `null`. */ | ||
export declare type Link = null | string | Array<string | null>; | ||
/** A link can be resolved into the entities it points to. The resulting structure is a ResolvedLink */ | ||
export declare type ResolvedLink = null | Entity | Array<Entity | null>; | ||
export declare type Records = Map<string, Entity>; | ||
export declare type Links = Map<string, Link>; | ||
export declare type Embedded = Map<string, Scalar>; | ||
export declare type Data = SystemFields & DataFields; | ||
export declare type Link<Key = string> = null | Key | NullArray<Key>; | ||
export declare type ResolvedLink = Link<Data>; | ||
export interface Variables { | ||
[name: string]: Scalar | Scalar[] | Variables | NullArray<Variables>; | ||
} | ||
export interface OperationRequest { | ||
query: DocumentNode; | ||
variables?: object; | ||
} | ||
export interface ResolveInfo { | ||
fragments: Fragments; | ||
variables: Variables; | ||
} | ||
export declare type Resolver = (parent: Data, args: Variables, cache: Store, info: ResolveInfo) => DataField; | ||
export interface ResolverConfig { | ||
[typeName: string]: { | ||
[fieldName: string]: Resolver; | ||
}; | ||
} | ||
export declare type UpdateResolver<T = any> = (result: T, args: Variables, cache: Store, info: ResolveInfo) => void; | ||
export interface UpdatesConfig { | ||
[mutationFieldName: string]: UpdateResolver; | ||
} | ||
export declare type OptimisticMutationResolver = (vars: Variables, cache: Store, info: ResolveInfo) => null | Data | NullArray<Data>; | ||
export interface OptimisticMutationConfig { | ||
[mutationFieldName: string]: OptimisticMutationResolver; | ||
} | ||
export declare type Completeness = 'EMPTY' | 'PARTIAL' | 'FULL'; |
115
package.json
{ | ||
"name": "@urql/exchange-graphcache", | ||
"version": "0.1.0-alpha.5", | ||
"description": "A normalizing GraphQL cache configurable by defining exceptions to the rule", | ||
"main": "dist/graphcache.js", | ||
"module": "dist/graphcache.es.js", | ||
"version": "1.0.0-beta.0", | ||
"description": "A normalized and configurable cache exchange for urql", | ||
"repository": "https://github.com/kitten/urql-exchange-graphcache", | ||
"bugs": { | ||
"url": "https://github.com/FormidableLabs/urql-exchange-graphcache/issues" | ||
}, | ||
"homepage": "https://github.com/FormidableLabs/urql-exchange-graphcache", | ||
"main": "dist/urql-exchange-graphcache.js", | ||
"module": "dist/urql-exchange-graphcache.es.js", | ||
"types": "dist/types/index.d.ts", | ||
@@ -11,17 +16,13 @@ "source": "src/index.ts", | ||
"scripts": { | ||
"prebuild": "rimraf dist", | ||
"build": "run-p build:types build:bundle", | ||
"build:clean": "rimraf dist", | ||
"build:types": "tsc -d --emitDeclarationOnly --outDir dist/types", | ||
"build:bundle": "microbundle --format es,cjs --no-compress", | ||
"build:prune": "rimraf dist/types/**/*.test.d.ts", | ||
"postbuild:bundle": "terser dist/graphcache.es.js -o dist/graphcache.es.min.js", | ||
"clean": "rimraf ./dist ./node_modules/.cache", | ||
"build": "rollup -c rollup.config.js", | ||
"watch": "rollup -w -c rollup.config.js", | ||
"check": "tsc --noEmit", | ||
"test": "jest", | ||
"bundlesize": "bundlesize", | ||
"lint": "tslint --project .", | ||
"check-formatting": "prettier --write src/**/*.{ts,tsx}", | ||
"prepublishOnly": "run-s build build:prune" | ||
"coverage": "jest --coverage", | ||
"lint": "eslint . --ext .ts,.tsx", | ||
"prepublishOnly": "run-s clean test build", | ||
"codecov": "codecov" | ||
}, | ||
"author": "Phil Plückthun <phil@kitten.sh>", | ||
"repository": "https://github.com/kitten/graphcache.git", | ||
"author": "Formidable", | ||
"license": "MIT", | ||
@@ -60,3 +61,3 @@ "prettier": { | ||
"*.{ts,tsx}": [ | ||
"tslint --fix", | ||
"eslint --fix", | ||
"prettier --write", | ||
@@ -71,44 +72,62 @@ "git add" | ||
}, | ||
"bundlesize": [ | ||
{ | ||
"path": "./dist/*.min.js", | ||
"maxSize": "10 kB" | ||
} | ||
], | ||
"dependencies": { | ||
"fast-json-stable-stringify": "^2.0.0", | ||
"wonka": "^2.0.1" | ||
"pessimism": "^1.0.1", | ||
"wonka": "^3.2.0" | ||
}, | ||
"peerDependencies": { | ||
"graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0", | ||
"urql": "^1.0.4" | ||
"urql": ">= 1.1.3" | ||
}, | ||
"devDependencies": { | ||
"@babel/core": "^7.5.5", | ||
"@babel/plugin-transform-object-assign": "^7.2.0", | ||
"@babel/plugin-transform-react-jsx": "^7.3.0", | ||
"@testing-library/react": "^8.0.7", | ||
"@types/enzyme": "3.10.3", | ||
"@types/fast-json-stable-stringify": "^2.0.0", | ||
"@types/graphql": "^14.0.7", | ||
"@types/jest": "^23.3.13", | ||
"@types/react": "^16.8.5", | ||
"@types/react-dom": "^16.8.2", | ||
"bundlesize": "^0.17.0", | ||
"coveralls": "^3.0.0", | ||
"graphql": "^14.1.1", | ||
"@types/graphql": "^14.2.3", | ||
"@types/jest": "^24.0.17", | ||
"@types/react": "16.8.24", | ||
"@types/react-test-renderer": "16.8.3", | ||
"@typescript-eslint/eslint-plugin": "^1.13.0", | ||
"@typescript-eslint/parser": "^1.13.0", | ||
"babel-plugin-closure-elimination": "^1.3.0", | ||
"babel-plugin-transform-async-to-promises": "^0.8.14", | ||
"codecov": "^3.5.0", | ||
"enzyme": "^3.10.0", | ||
"enzyme-adapter-react-16": "^1.14.0", | ||
"enzyme-to-json": "^3.4.0", | ||
"eslint": "^6.1.0", | ||
"eslint-config-prettier": "^6.0.0", | ||
"eslint-plugin-import": "^2.18.2", | ||
"eslint-plugin-react": "^7.14.3", | ||
"eslint-plugin-react-hooks": "^1.6.1", | ||
"graphql": "^14.4.2", | ||
"graphql-tag": "^2.10.1", | ||
"husky": "^1.2.0", | ||
"jest": "^23.6.0", | ||
"lint-staged": "^8.1.0", | ||
"microbundle": "^0.9.0", | ||
"husky": "^3.0.2", | ||
"jest": "^24.8.0", | ||
"lint-staged": "^9.2.1", | ||
"npm-run-all": "^4.1.5", | ||
"prettier": "^1.15.2", | ||
"prop-types": "^15.7.2", | ||
"react": "^16.8.3", | ||
"react-dom": "^16.8.3", | ||
"prettier": "^1.17.1", | ||
"react": "^16.8.6", | ||
"react-dom": "^16.8.6", | ||
"react-hooks-testing-library": "^0.6.0", | ||
"react-is": "^16.8.6", | ||
"react-ssr-prepass": "^1.0.6", | ||
"react-test-renderer": "^16.8.6", | ||
"rimraf": "^2.6.2", | ||
"terser": "^3.16.1", | ||
"ts-jest": "^23.10.5", | ||
"tslint": "^5.11.0", | ||
"tslint-config-prettier": "^1.16.0", | ||
"tslint-react": "^3.6.0", | ||
"typescript": "^3.1.6", | ||
"urql": "^1.0.4" | ||
"rollup": "^1.19.3", | ||
"rollup-plugin-babel": "^4.3.3", | ||
"rollup-plugin-buble": "^0.19.8", | ||
"rollup-plugin-commonjs": "^10.0.2", | ||
"rollup-plugin-node-resolve": "^5.2.0", | ||
"rollup-plugin-replace": "^2.2.0", | ||
"rollup-plugin-terser": "^5.1.1", | ||
"rollup-plugin-typescript2": "^0.22.1", | ||
"terser": "^4.1.3", | ||
"ts-jest": "^24.0.2", | ||
"typescript": "^3.5.3", | ||
"urql": "^1.3.0" | ||
} | ||
} |
@@ -1,126 +0,3 @@ | ||
export * from './types'; | ||
export { getFieldArguments, normalizeVariables } from './variables'; | ||
export * from './traversal'; | ||
export * from './node'; | ||
import { | ||
DocumentNode, | ||
FieldNode, | ||
FragmentDefinitionNode, | ||
InlineFragmentNode, | ||
OperationDefinitionNode, | ||
SelectionNode, | ||
} from 'graphql'; | ||
import { getName, isFragmentNode, isOperationNode } from './node'; | ||
import { Fragments, VarsMap } from './types'; | ||
import { evaluateValueNode } from './value'; | ||
/** Checks whether a SelectionNode is a FieldNode */ | ||
export const isFieldNode = (node: SelectionNode): node is FieldNode => | ||
node.kind === 'Field'; | ||
/** Checks whether a SelectionNode is an InlineFragmentNode */ | ||
export const isInlineFragment = ( | ||
node: SelectionNode | ||
): node is InlineFragmentNode => node.kind === 'InlineFragment'; | ||
/** Returns the main operation's definition */ | ||
export const getMainOperation = ( | ||
doc: DocumentNode | ||
): OperationDefinitionNode | void => { | ||
return doc.definitions.find(isOperationNode) as OperationDefinitionNode; | ||
}; | ||
/** Returns the first fragment definition */ | ||
export const getMainFragment = ( | ||
doc: DocumentNode | ||
): FragmentDefinitionNode | void => { | ||
return doc.definitions.find(isFragmentNode) as FragmentDefinitionNode; | ||
}; | ||
/** Returns a normalized form of variables with defaulted values */ | ||
export const getNormalizedVars = ( | ||
node: OperationDefinitionNode, | ||
input?: null | object | ||
): VarsMap => { | ||
if (node.variableDefinitions === undefined) { | ||
return {}; | ||
} | ||
const args: VarsMap = input ? (input as VarsMap) : {}; | ||
return node.variableDefinitions.reduce((vars, def) => { | ||
const name = getName(def.variable); | ||
let value = args[name]; | ||
if (value === undefined) { | ||
if (def.defaultValue !== undefined) { | ||
value = evaluateValueNode(def.defaultValue, args); | ||
} else { | ||
return vars; | ||
} | ||
} | ||
vars[name] = value; | ||
return vars; | ||
}, {}); | ||
}; | ||
/** Returns a mapping from fragment names to their selections */ | ||
export const getFragments = (doc: DocumentNode): Fragments => | ||
doc.definitions.filter(isFragmentNode).reduce((map: Fragments, node) => { | ||
map[getName(node)] = node; | ||
return map; | ||
}, {}); | ||
/** Returns either the field's name or the field's alias */ | ||
export const getFieldAlias = (node: FieldNode): string => | ||
node.alias !== undefined ? node.alias.value : getName(node); | ||
/** Evaluates a fields arguments taking vars into account */ | ||
export const getFieldArguments = ( | ||
node: FieldNode, | ||
vars: VarsMap | ||
): null | VarsMap => { | ||
if (node.arguments === undefined || node.arguments.length === 0) { | ||
return null; | ||
} | ||
return node.arguments.reduce((args, arg) => { | ||
args[getName(arg)] = evaluateValueNode(arg.value, vars); | ||
return args; | ||
}, {}); | ||
}; | ||
/** Checks whether a given SelectionNode should be ignored based on @skip or @include directives */ | ||
export const shouldInclude = (node: SelectionNode, vars: VarsMap): boolean => { | ||
if (node.directives === undefined) { | ||
return true; | ||
} | ||
// Finds any @include or @skip directive that forces the node to be skipped | ||
return !node.directives.some(directive => { | ||
const name = getName(directive); | ||
// Ignore other directives | ||
const isInclude = name === 'include'; | ||
if (!isInclude && name !== 'skip') { | ||
return false; | ||
} | ||
// Get the first argument and expect it to be named "if" | ||
const firstArg = | ||
directive.arguments !== undefined ? directive.arguments[0] : null; | ||
if (firstArg === null) { | ||
return false; | ||
} else if (getName(firstArg) !== 'if') { | ||
return false; | ||
} | ||
const value = evaluateValueNode(firstArg.value, vars); | ||
if (typeof value !== 'boolean' && value !== null) { | ||
return false; | ||
} | ||
// Return whether this directive forces us to skip | ||
// `@include(if: false)` or `@skip(if: true)` | ||
return isInclude ? !value : !!value; | ||
}); | ||
}; |
import { | ||
DefinitionNode, | ||
FragmentDefinitionNode, | ||
NamedTypeNode, | ||
NameNode, | ||
SelectionNode, | ||
SelectionSetNode, | ||
InlineFragmentNode, | ||
FieldNode, | ||
OperationDefinitionNode, | ||
SelectionSetNode, | ||
FragmentDefinitionNode, | ||
Kind, | ||
} from 'graphql'; | ||
import { OperationType, SelectionSet } from './types'; | ||
import { SelectionSet } from '../types'; | ||
@@ -15,2 +18,20 @@ /** Returns the name of a given node */ | ||
export const getOperationName = (node: OperationDefinitionNode) => { | ||
switch (node.operation) { | ||
case 'query': | ||
return 'Query'; | ||
case 'mutation': | ||
return 'Mutation'; | ||
case 'subscription': | ||
return 'Subscription'; | ||
} | ||
}; | ||
export const getFragmentTypeName = (node: FragmentDefinitionNode): string => | ||
node.typeCondition.name.value; | ||
/** Returns either the field's name or the field's alias */ | ||
export const getFieldAlias = (node: FieldNode): string => | ||
node.alias !== undefined ? node.alias.value : getName(node); | ||
/** Returns the SelectionSet for a given inline or defined fragment node */ | ||
@@ -29,21 +50,7 @@ export const getSelectionSet = (node: { | ||
export const isOperationNode = ( | ||
node: DefinitionNode | ||
): node is OperationDefinitionNode => node.kind === 'OperationDefinition'; | ||
export const isFieldNode = (node: SelectionNode): node is FieldNode => | ||
node.kind === Kind.FIELD; | ||
export const isFragmentNode = ( | ||
node: DefinitionNode | ||
): node is FragmentDefinitionNode => node.kind === 'FragmentDefinition'; | ||
export const getOperationType = ( | ||
node: OperationDefinitionNode | ||
): OperationType => { | ||
switch (node.operation) { | ||
case 'query': | ||
return 'Query'; | ||
case 'mutation': | ||
return 'Mutation'; | ||
case 'subscription': | ||
return 'Subscription'; | ||
} | ||
}; | ||
export const isInlineFragment = ( | ||
node: SelectionNode | ||
): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT; |
@@ -8,9 +8,16 @@ import { | ||
} from 'urql'; | ||
import { filter, map, merge, pipe, share, tap } from 'wonka'; | ||
import { query, write, writeOptimistic } from './operations'; | ||
import { Store } from './store'; | ||
import { query, write } from './operations'; | ||
import { create, SerializedStore } from './store'; | ||
import { | ||
Completeness, | ||
UpdatesConfig, | ||
ResolverConfig, | ||
OptimisticMutationConfig, | ||
} from './types'; | ||
type OperationResultWithMeta = OperationResult & { | ||
isComplete: boolean; | ||
completeness: Completeness; | ||
}; | ||
@@ -34,21 +41,19 @@ | ||
// Returns whether an operation is a query | ||
const isQueryOperation = (op: Operation): boolean => { | ||
const isQueryOperation = (op: Operation): boolean => | ||
op.operationName === 'query'; | ||
// Returns whether an operation is a mutation | ||
const isMutationOperation = (op: Operation): boolean => | ||
op.operationName === 'mutation'; | ||
// Returns whether an operation can potentially be read from cache | ||
const isCacheableQuery = (op: Operation): boolean => { | ||
const policy = getRequestPolicy(op); | ||
return ( | ||
op.operationName === 'query' && | ||
(policy === 'cache-and-network' || | ||
policy === 'cache-first' || | ||
policy === 'cache-only') | ||
); | ||
return isQueryOperation(op) && policy !== 'network-only'; | ||
}; | ||
// Returns whether an operation is handled by this exchange | ||
const isCacheableQuery = (op: Operation): boolean => { | ||
// Returns whether an operation potentially triggers an optimistic update | ||
const isOptimisticMutation = (op: Operation): boolean => { | ||
const policy = getRequestPolicy(op); | ||
return ( | ||
isQueryOperation(op) && | ||
(policy === 'cache-and-network' || | ||
policy === 'cache-first' || | ||
policy === 'cache-only') | ||
); | ||
return isMutationOperation(op) && policy !== 'network-only'; | ||
}; | ||
@@ -69,3 +74,5 @@ | ||
export interface CacheExchangeOpts { | ||
initial?: SerializedStore; | ||
updates?: UpdatesConfig; | ||
resolvers?: ResolverConfig; | ||
optimistic?: OptimisticMutationConfig; | ||
} | ||
@@ -77,3 +84,4 @@ | ||
}) => { | ||
const store = create(opts.initial); | ||
const store = new Store(opts.resolvers, opts.updates, opts.optimistic); | ||
const optimisticKeys = new Set(); | ||
const ops: OperationMap = new Map(); | ||
@@ -87,3 +95,3 @@ const deps = Object.create(null) as DependentOperations; | ||
triggerOp: Operation, | ||
dependencies: string[] | ||
dependencies: Set<string> | ||
) => { | ||
@@ -111,4 +119,16 @@ const pendingOperations = new Set<number>(); | ||
// This executes an optimistic update for mutations and registers it if necessary | ||
const optimisticUpdate = (operation: Operation) => { | ||
if (isOptimisticMutation(operation)) { | ||
const { key } = operation; | ||
const { dependencies } = writeOptimistic(store, operation, key); | ||
if (dependencies.size !== 0) { | ||
optimisticKeys.add(key); | ||
processDependencies(operation, dependencies); | ||
} | ||
} | ||
}; | ||
// This updates the known dependencies for the passed operation | ||
const updateDependencies = (op: Operation, dependencies: string[]) => { | ||
const updateDependencies = (op: Operation, dependencies: Set<string>) => { | ||
dependencies.forEach(dep => { | ||
@@ -135,3 +155,3 @@ const keys = deps[dep] || (deps[dep] = []); | ||
const res = query(store, operation); | ||
const isComplete = policy === 'cache-only' || res.isComplete; | ||
const isComplete = policy === 'cache-only' || res.completeness === 'FULL'; | ||
if (isComplete) { | ||
@@ -143,3 +163,3 @@ updateDependencies(operation, res.dependencies); | ||
operation, | ||
isComplete, | ||
completeness: isComplete ? 'FULL' : 'EMPTY', | ||
data: res.data, | ||
@@ -150,16 +170,19 @@ }; | ||
// Take any OperationResult and update the cache with it | ||
const updateCacheWithResult = ({ | ||
error, | ||
data, | ||
operation, | ||
}: OperationResult) => { | ||
if ( | ||
(error === undefined || error.networkError === undefined) && | ||
data !== null && | ||
data !== undefined | ||
) { | ||
const { dependencies } = write(store, operation, data); | ||
const updateCacheWithResult = ({ data, operation }: OperationResult) => { | ||
let dependencies; | ||
if (data !== null && data !== undefined) { | ||
dependencies = write(store, operation, data).dependencies; | ||
} | ||
// Clear old optimistic values from the store | ||
const { key } = operation; | ||
if (optimisticKeys.has(key)) { | ||
optimisticKeys.delete(key); | ||
store.clearOptimistic(key); | ||
} | ||
if (dependencies !== undefined) { | ||
// Update operations that depend on the updated data (except the current one) | ||
processDependencies(operation, dependencies); | ||
// Update this operation's dependencies if it's a query | ||
@@ -176,2 +199,3 @@ if (isQueryOperation(operation)) { | ||
map(addTypeNames), | ||
tap(optimisticUpdate), | ||
share | ||
@@ -191,3 +215,3 @@ ); | ||
cache$, | ||
filter(res => !res.isComplete), | ||
filter(res => res.completeness !== 'FULL'), | ||
map(res => res.operation) | ||
@@ -200,3 +224,3 @@ ); | ||
cache$, | ||
filter(res => res.isComplete), | ||
filter(res => res.completeness === 'FULL'), | ||
tap(({ operation }) => { | ||
@@ -203,0 +227,0 @@ const policy = getRequestPolicy(operation); |
import stringify from 'fast-json-stable-stringify'; | ||
import { VarsMap } from '../ast'; | ||
import { SystemFields } from '../types'; | ||
import { Variables, SystemFields } from '../types'; | ||
@@ -25,6 +24,6 @@ export const isOperation = (typeName: string) => | ||
export const keyOfField = (fieldName: string, args: null | VarsMap) => | ||
args !== null ? `${fieldName}(${stringify(args)})` : fieldName; | ||
export const keyOfField = (fieldName: string, args?: null | Variables) => | ||
args ? `${fieldName}(${stringify(args)})` : fieldName; | ||
export const joinKeys = (parentKey: string, key: string) => | ||
`${parentKey}.${key}`; |
export * from './operations'; | ||
export * from './types'; | ||
export { create, serialize } from './store'; | ||
export { Store } from './store'; | ||
export { cacheExchange } from './exchange'; |
export { query } from './query'; | ||
export { write } from './write'; | ||
export { gc } from './gc'; | ||
export { write, writeOptimistic, writeFragment } from './write'; |
import { | ||
getFragments, | ||
getMainOperation, | ||
getSelectionSet, | ||
normalizeVariables, | ||
getName, | ||
getFieldArguments, | ||
getFieldAlias, | ||
getFieldArguments, | ||
getName, | ||
getSelectionSet, | ||
SelectionSet, | ||
} from '../ast'; | ||
import { joinKeys, keyOfField } from '../helpers'; | ||
import { find, readLink, Store } from '../store'; | ||
import { Entity, Link } from '../types'; | ||
import { | ||
Fragments, | ||
Variables, | ||
Data, | ||
DataField, | ||
Link, | ||
SelectionSet, | ||
Completeness, | ||
OperationRequest, | ||
} from '../types'; | ||
import { forEachFieldNode, makeContext } from './shared'; | ||
import { Context, Data, Request, Result } from './types'; | ||
import { | ||
Store, | ||
addDependency, | ||
getCurrentDependencies, | ||
initStoreState, | ||
clearStoreState, | ||
} from '../store'; | ||
import { forEachFieldNode } from './shared'; | ||
import { joinKeys, keyOfEntity, keyOfField } from '../helpers'; | ||
export interface QueryResult { | ||
completeness: Completeness; | ||
dependencies: Set<string>; | ||
data: null | Data; | ||
} | ||
interface Context { | ||
result: QueryResult; | ||
store: Store; | ||
variables: Variables; | ||
fragments: Fragments; | ||
} | ||
/** Reads a request entirely from the store */ | ||
export const query = (store: Store, request: Request): Result => { | ||
const ctx = makeContext(store, request); | ||
if (ctx === undefined) { | ||
return { isComplete: false, dependencies: [] }; | ||
} | ||
export const query = (store: Store, request: OperationRequest): QueryResult => { | ||
initStoreState(0); | ||
const select = getSelectionSet(ctx.operation); | ||
const data = readEntity(ctx, 'Query', select, Object.create(null)); | ||
const operation = getMainOperation(request.query); | ||
const root: Data = Object.create(null); | ||
const result: QueryResult = { | ||
completeness: 'FULL', | ||
dependencies: getCurrentDependencies(), | ||
data: root, | ||
}; | ||
return { | ||
data, | ||
isComplete: ctx.isComplete, | ||
dependencies: ctx.dependencies, | ||
const ctx: Context = { | ||
variables: normalizeVariables(operation, request.variables), | ||
fragments: getFragments(request.query), | ||
result, | ||
store, | ||
}; | ||
result.data = readSelection(ctx, 'Query', getSelectionSet(operation), root); | ||
clearStoreState(); | ||
return result; | ||
}; | ||
const readEntity = ( | ||
const readSelection = ( | ||
ctx: Context, | ||
key: string, | ||
entityKey: string, | ||
select: SelectionSet, | ||
data: Data | ||
): Data | null => { | ||
const { store } = ctx; | ||
const entity = find(store, key); | ||
if (entity === null) { | ||
// Cache Incomplete: A missing entity for a key means it wasn't cached | ||
ctx.isComplete = false; | ||
const isQuery = entityKey === 'Query'; | ||
if (!isQuery) addDependency(entityKey); | ||
const { store, variables } = ctx; | ||
// Get the __typename field for a given entity to check that it exists | ||
const typename = store.getField(entityKey, '__typename'); | ||
if (typeof typename !== 'string') { | ||
ctx.result.completeness = 'EMPTY'; | ||
return null; | ||
} else if (key !== 'Query') { | ||
ctx.dependencies.push(key); | ||
} | ||
return readSelection(ctx, entity, key, select, data); | ||
}; | ||
data.__typename = typename; | ||
const readSelection = ( | ||
ctx: Context, | ||
entity: Entity, | ||
key: string, | ||
select: SelectionSet, | ||
data: Data | ||
): Data => { | ||
data.__typename = entity.__typename as string; | ||
forEachFieldNode(ctx, select, node => { | ||
const { store, vars } = ctx; | ||
forEachFieldNode(typename, entityKey, select, ctx, node => { | ||
// Derive the needed data from our node. | ||
const fieldName = getName(node); | ||
// The field's key can include arguments if it has any | ||
const fieldKey = keyOfField(fieldName, getFieldArguments(node, vars)); | ||
const fieldValue = entity[fieldKey]; | ||
const fieldArgs = getFieldArguments(node, variables); | ||
const fieldAlias = getFieldAlias(node); | ||
const childFieldKey = joinKeys(key, fieldKey); | ||
if (key === 'Query') { | ||
ctx.dependencies.push(childFieldKey); | ||
} | ||
const fieldKey = joinKeys(entityKey, keyOfField(fieldName, fieldArgs)); | ||
const fieldValue = store.getRecord(fieldKey); | ||
if (fieldValue === undefined) { | ||
// Cache Incomplete: A missing field means it wasn't cached | ||
ctx.isComplete = false; | ||
data[fieldAlias] = null; | ||
} else if (node.selectionSet === undefined || fieldValue !== null) { | ||
data[fieldAlias] = fieldValue; | ||
if (isQuery) addDependency(fieldKey); | ||
const resolvers = store.resolvers[typename]; | ||
if (resolvers !== undefined && resolvers.hasOwnProperty(fieldName)) { | ||
// We have a resolver for this field. | ||
const resolverValue = resolvers[fieldName]( | ||
data, | ||
fieldArgs || {}, | ||
store, | ||
ctx | ||
); | ||
if (node.selectionSet === undefined) { | ||
// If it doesn't have a selection set we have resolved a property. | ||
// We assume that a resolver for scalar values implies that this | ||
// field is always present, so completeness won't be set to EMPTY here | ||
data[fieldAlias] = resolverValue !== undefined ? resolverValue : null; | ||
} else { | ||
// When it has a selection set we are resolving an entity with a | ||
// subselection. This can either be a list or an object. | ||
const fieldSelect = getSelectionSet(node); | ||
data[fieldAlias] = resolveResolverResult( | ||
ctx, | ||
resolverValue, | ||
fieldKey, | ||
fieldSelect, | ||
data[fieldAlias] as Data | Data[] | ||
); | ||
} | ||
} else if (node.selectionSet === undefined) { | ||
// The field is a scalar and can be retrieved directly | ||
if (fieldValue === undefined) { | ||
// Cache Incomplete: A missing field means it wasn't cached | ||
ctx.result.completeness = 'EMPTY'; | ||
data[fieldAlias] = null; | ||
} else { | ||
// Not dealing with null means it's a regular property. | ||
data[fieldAlias] = fieldValue; | ||
} | ||
} else { | ||
// null values mean that a field might be linked to other entities | ||
const fieldSelect = getSelectionSet(node); | ||
const link = readLink(store, childFieldKey); | ||
const link = store.getLink(fieldKey); | ||
// Cache Incomplete: A missing link for a field means it's not cached | ||
if (link === undefined) { | ||
ctx.isComplete = false; | ||
data[fieldAlias] = null; | ||
if (typeof fieldValue === 'object' && fieldValue !== null) { | ||
// The entity on the field was invalid and can still be recovered | ||
data[fieldAlias] = fieldValue; | ||
} else { | ||
ctx.result.completeness = 'EMPTY'; | ||
data[fieldAlias] = null; | ||
} | ||
} else { | ||
const prevData = data[fieldAlias] as Data; | ||
data[fieldAlias] = readField(ctx, link, fieldSelect, prevData); | ||
data[fieldAlias] = resolveLink(ctx, link, fieldSelect, prevData); | ||
} | ||
@@ -99,7 +162,53 @@ } | ||
const readField = ( | ||
const resolveResolverResult = ( | ||
ctx: Context, | ||
link: Link, | ||
result: DataField, | ||
key: string, | ||
select: SelectionSet, | ||
prevData: void | Data | Data[] | ||
) => { | ||
// When we are dealing with a list we have to call this method again. | ||
if (Array.isArray(result)) { | ||
// @ts-ignore: Link cannot be expressed as a recursive type | ||
return result.map((childResult, index) => { | ||
const data = prevData !== undefined ? prevData[index] : undefined; | ||
const indexKey = joinKeys(key, `${index}`); | ||
return resolveResolverResult(ctx, childResult, indexKey, select, data); | ||
}); | ||
} else if (result === null) { | ||
return null; | ||
} else if (isDataOrKey(result)) { | ||
// We don't need to read the entity after exiting a resolver | ||
// we can just go on and read the selection further. | ||
const data = prevData === undefined ? Object.create(null) : prevData; | ||
const childKey = | ||
(typeof result === 'string' ? result : keyOfEntity(result)) || key; | ||
const selectionResult = readSelection(ctx, childKey, select, data); | ||
if (selectionResult !== null && typeof result === 'object') { | ||
for (key in result) { | ||
if (key !== '__typename' && result.hasOwnProperty(key)) { | ||
selectionResult[key] = result[key]; | ||
} | ||
} | ||
} | ||
return selectionResult; | ||
} | ||
if (process.env.NODE_ENV !== 'production') { | ||
console.warn( | ||
'Expected to receive a Link or an Entity from a Resolver but got a Scalar.' | ||
); | ||
} | ||
ctx.result.completeness = 'EMPTY'; | ||
return null; | ||
}; | ||
const resolveLink = ( | ||
ctx: Context, | ||
link: Link | Link[], | ||
select: SelectionSet, | ||
prevData: void | Data | Data[] | ||
): null | Data | Data[] => { | ||
@@ -110,3 +219,3 @@ if (Array.isArray(link)) { | ||
const data = prevData !== undefined ? prevData[index] : undefined; | ||
return readField(ctx, childLink, select, data); | ||
return resolveLink(ctx, childLink, select, data); | ||
}); | ||
@@ -117,4 +226,10 @@ } else if (link === null) { | ||
const data = prevData === undefined ? Object.create(null) : prevData; | ||
return readEntity(ctx, link, select, data); | ||
return readSelection(ctx, link, select, data); | ||
} | ||
}; | ||
const isDataOrKey = (x: any): x is string | Data => | ||
typeof x === 'string' || | ||
(typeof x === 'object' && | ||
x !== null && | ||
typeof (x as any).__typename === 'string'); |
@@ -1,42 +0,51 @@ | ||
import { FieldNode } from 'graphql'; | ||
import { FieldNode, InlineFragmentNode, FragmentDefinitionNode } from 'graphql'; | ||
import { Fragments, Variables, SelectionSet } from '../types'; | ||
import { Store } from '../store'; | ||
import { joinKeys, keyOfField } from '../helpers'; | ||
import { | ||
getFragments, | ||
getMainOperation, | ||
getName, | ||
getNormalizedVars, | ||
getSelectionSet, | ||
getTypeCondition, | ||
getFieldArguments, | ||
shouldInclude, | ||
isFieldNode, | ||
isInlineFragment, | ||
SelectionSet, | ||
shouldInclude, | ||
getSelectionSet, | ||
getName, | ||
} from '../ast'; | ||
import { Store } from '../store'; | ||
import { Context, Request } from './types'; | ||
interface Context { | ||
store: Store; | ||
variables: Variables; | ||
fragments: Fragments; | ||
} | ||
export const makeContext = (store: Store, request: Request): void | Context => { | ||
const { query, variables } = request; | ||
const operation = getMainOperation(query); | ||
if (operation === undefined) { | ||
return; | ||
const isFragmentMatching = ( | ||
node: InlineFragmentNode | FragmentDefinitionNode, | ||
typename: string, | ||
entityKey: string, | ||
ctx: Context | ||
) => { | ||
if (typename === getTypeCondition(node)) { | ||
return true; | ||
} | ||
const dependencies = []; | ||
const fragments = getFragments(query); | ||
const vars = getNormalizedVars(operation, variables); | ||
const isComplete = true; | ||
return { dependencies, isComplete, operation, fragments, vars, store }; | ||
// This is a heuristic for now, but temporary until schema awareness becomes a thing | ||
return !getSelectionSet(node).some(node => { | ||
if (!isFieldNode(node)) return false; | ||
const fieldName = getName(node); | ||
const fieldArgs = getFieldArguments(node, ctx.variables); | ||
const fieldKey = keyOfField(fieldName, fieldArgs); | ||
return !ctx.store.hasField(joinKeys(entityKey, fieldKey)); | ||
}); | ||
}; | ||
export const forEachFieldNode = ( | ||
typename: string, | ||
entityKey: string, | ||
select: SelectionSet, | ||
ctx: Context, | ||
select: SelectionSet, | ||
cb: (node: FieldNode) => void | ||
) => { | ||
const { vars, fragments } = ctx; | ||
select.forEach(node => { | ||
if (!shouldInclude(node, vars)) { | ||
if (!shouldInclude(node, ctx.variables)) { | ||
// Directives instruct this node to be skipped | ||
@@ -46,9 +55,11 @@ return; | ||
// A fragment is either referred to by FragmentSpread or inline | ||
const def = isInlineFragment(node) ? node : fragments[getName(node)]; | ||
if (def !== undefined) { | ||
const fragmentSelect = getSelectionSet(def); | ||
// TODO: Check for getTypeCondition(def) to match | ||
// Recursively process the fragments' selection sets | ||
forEachFieldNode(ctx, fragmentSelect, cb); | ||
const fragmentNode = isInlineFragment(node) | ||
? node | ||
: ctx.fragments[getName(node)]; | ||
if ( | ||
fragmentNode !== undefined && | ||
isFragmentMatching(fragmentNode, typename, entityKey, ctx) | ||
) { | ||
const fragmentSelect = getSelectionSet(fragmentNode); | ||
forEachFieldNode(typename, entityKey, fragmentSelect, ctx, cb); | ||
} | ||
@@ -55,0 +66,0 @@ } else if (getName(node) !== '__typename') { |
import { | ||
getFieldAlias, | ||
getFragments, | ||
getMainOperation, | ||
getSelectionSet, | ||
normalizeVariables, | ||
getFragmentTypeName, | ||
getName, | ||
getOperationName, | ||
getFieldArguments, | ||
getName, | ||
getOperationType, | ||
getSelectionSet, | ||
SelectionSet, | ||
} from '../ast'; | ||
import { | ||
NullArray, | ||
Fragments, | ||
Variables, | ||
Data, | ||
Link, | ||
Scalar, | ||
SelectionSet, | ||
OperationRequest, | ||
} from '../types'; | ||
import { | ||
Store, | ||
addDependency, | ||
getCurrentDependencies, | ||
initStoreState, | ||
clearStoreState, | ||
} from '../store'; | ||
import { forEachFieldNode } from './shared'; | ||
import { joinKeys, keyOfEntity, keyOfField } from '../helpers'; | ||
import { findOrCreate, removeLink, setLink, Store } from '../store'; | ||
import { Entity, Link, Scalar } from '../types'; | ||
import { DocumentNode, FragmentDefinitionNode } from 'graphql'; | ||
import { forEachFieldNode, makeContext } from './shared'; | ||
import { Context, Data, Request, Result } from './types'; | ||
export interface WriteResult { | ||
touched: string[]; | ||
dependencies: Set<string>; | ||
} | ||
interface Context { | ||
result: WriteResult; | ||
store: Store; | ||
variables: Variables; | ||
fragments: Fragments; | ||
} | ||
/** Writes a request given its response to the store */ | ||
export const write = (store: Store, request: Request, data: Data): Result => { | ||
const ctx = makeContext(store, request); | ||
if (ctx === undefined) { | ||
return { isComplete: false, dependencies: [] }; | ||
} | ||
export const write = ( | ||
store: Store, | ||
request: OperationRequest, | ||
data: Data | ||
): WriteResult => { | ||
initStoreState(0); | ||
const { operation } = ctx; | ||
const operation = getMainOperation(request.query); | ||
const result: WriteResult = { dependencies: getCurrentDependencies() }; | ||
const ctx: Context = { | ||
variables: normalizeVariables(operation, request.variables), | ||
fragments: getFragments(request.query), | ||
result, | ||
store, | ||
}; | ||
const select = getSelectionSet(operation); | ||
const operationType = getOperationType(operation); | ||
const operationName = getOperationName(operation); | ||
if (typeof data.__typename !== 'string') { | ||
data.__typename = operationType; | ||
if (operationName === 'Query') { | ||
writeSelection(ctx, operationName, select, data); | ||
} else { | ||
writeRoot(ctx, operationName, select, data); | ||
} | ||
if (operationType === 'Query') { | ||
writeEntity(ctx, operationType, data, select); | ||
} else { | ||
writeRoot(ctx, data, select); | ||
clearStoreState(); | ||
return result; | ||
}; | ||
export const writeOptimistic = ( | ||
store: Store, | ||
request: OperationRequest, | ||
optimisticKey: number | ||
): WriteResult => { | ||
initStoreState(optimisticKey); | ||
const operation = getMainOperation(request.query); | ||
const result: WriteResult = { dependencies: getCurrentDependencies() }; | ||
const ctx: Context = { | ||
variables: normalizeVariables(operation, request.variables), | ||
fragments: getFragments(request.query), | ||
result, | ||
store, | ||
}; | ||
const operationName = getOperationName(operation); | ||
if (operationName === 'Mutation') { | ||
const select = getSelectionSet(operation); | ||
forEachFieldNode(operationName, operationName, select, ctx, node => { | ||
if (node.selectionSet !== undefined) { | ||
const fieldName = getName(node); | ||
const resolver = ctx.store.optimisticMutations[fieldName]; | ||
if (resolver !== undefined) { | ||
const fieldArgs = getFieldArguments(node, ctx.variables); | ||
const fieldSelect = getSelectionSet(node); | ||
const resolverValue = resolver(fieldArgs || {}, ctx.store, ctx); | ||
if (!isScalar(resolverValue)) { | ||
writeRootField(ctx, resolverValue, fieldSelect); | ||
} | ||
} | ||
} | ||
}); | ||
} | ||
return { isComplete: true, dependencies: ctx.dependencies }; | ||
clearStoreState(); | ||
return result; | ||
}; | ||
const writeEntity = ( | ||
ctx: Context, | ||
key: string, | ||
data: Data, | ||
select: SelectionSet | ||
export const writeFragment = ( | ||
store: Store, | ||
query: DocumentNode, | ||
data: Data | ||
) => { | ||
const { store } = ctx; | ||
const entity = findOrCreate(store, key); | ||
if (key !== 'Query') { | ||
ctx.dependencies.push(key); | ||
const fragments = getFragments(query); | ||
const names = Object.keys(fragments); | ||
const fragment = fragments[names[0]] as FragmentDefinitionNode; | ||
if (process.env.NODE_ENV !== 'production' && fragment === undefined) { | ||
throw new Error('At least one fragment must be passed to writeFragment.'); | ||
} | ||
writeSelection(ctx, entity, key, data, select); | ||
const select = getSelectionSet(fragment); | ||
const fieldName = getFragmentTypeName(fragment); | ||
const writeData = { ...data, __typename: fieldName } as Data; | ||
const entityKey = keyOfEntity(writeData) as string; | ||
if (process.env.NODE_ENV !== 'production' && !entityKey) { | ||
throw new Error( | ||
`You have to pass an "id" or "_id" with your writeFragment data.` | ||
); | ||
} | ||
const ctx: Context = { | ||
variables: {}, // TODO: Should we support variables? | ||
fragments, | ||
result: { dependencies: getCurrentDependencies() }, | ||
store, | ||
}; | ||
writeSelection(ctx, entityKey, select, writeData); | ||
}; | ||
@@ -62,38 +153,31 @@ | ||
ctx: Context, | ||
entity: Entity, | ||
key: string, | ||
data: Data, | ||
select: SelectionSet | ||
entityKey: string, | ||
select: SelectionSet, | ||
data: Data | ||
) => { | ||
entity.__typename = data.__typename; | ||
const isQuery = entityKey === 'Query'; | ||
if (!isQuery) addDependency(entityKey); | ||
forEachFieldNode(ctx, select, node => { | ||
const { store, vars } = ctx; | ||
const { store, variables } = ctx; | ||
const typename = data.__typename; | ||
store.writeField(typename, entityKey, '__typename'); | ||
forEachFieldNode(typename, entityKey, select, ctx, node => { | ||
const fieldName = getName(node); | ||
const fieldArgs = getFieldArguments(node, variables); | ||
const fieldKey = joinKeys(entityKey, keyOfField(fieldName, fieldArgs)); | ||
const fieldValue = data[getFieldAlias(node)]; | ||
// The field's key can include arguments if it has any | ||
const fieldKey = keyOfField(fieldName, getFieldArguments(node, vars)); | ||
const childFieldKey = joinKeys(key, fieldKey); | ||
if (key === 'Query' && fieldName !== '__typename') { | ||
ctx.dependencies.push(childFieldKey); | ||
} | ||
if ( | ||
node.selectionSet === undefined || | ||
fieldValue === null || | ||
isScalar(fieldValue) | ||
) { | ||
if (isQuery) addDependency(fieldKey); | ||
if (node.selectionSet === undefined) { | ||
// This is a leaf node, so we're setting the field's value directly | ||
entity[fieldKey] = fieldValue; | ||
// Remove any links that might've existed before for this field | ||
removeLink(store, childFieldKey); | ||
} else { | ||
// Ensure that this key exists on the entity and that previous values are thrown away | ||
entity[fieldKey] = null; | ||
store.writeRecord(fieldValue, fieldKey); | ||
} else if (!isScalar(fieldValue)) { | ||
// Process the field and write links for the child entities that have been written | ||
const { selections: fieldSelect } = node.selectionSet; | ||
const link = writeField(ctx, childFieldKey, fieldValue, fieldSelect); | ||
setLink(store, childFieldKey, link); | ||
const link = writeField(ctx, fieldKey, fieldSelect, fieldValue); | ||
store.writeLink(link, fieldKey); | ||
store.removeRecord(fieldKey); | ||
} else { | ||
// This is a rare case for invalid entities | ||
store.writeRecord(fieldValue, fieldKey); | ||
} | ||
@@ -106,4 +190,4 @@ }); | ||
parentFieldKey: string, | ||
data: Data | Data[] | null, | ||
select: SelectionSet | ||
select: SelectionSet, | ||
data: null | Data | NullArray<Data> | ||
): Link => { | ||
@@ -115,3 +199,3 @@ if (Array.isArray(data)) { | ||
// Recursively write array data | ||
const links = writeField(ctx, indexKey, item, select); | ||
const links = writeField(ctx, indexKey, select, item); | ||
// Link cannot be expressed as a recursive type | ||
@@ -124,6 +208,5 @@ return links as string | null; | ||
// Write entity to key that falls back to the given parentFieldKey | ||
const entityKey = keyOfEntity(data); | ||
const key = entityKey !== null ? entityKey : parentFieldKey; | ||
writeEntity(ctx, key, data, select); | ||
writeSelection(ctx, key, select, data); | ||
return key; | ||
@@ -133,5 +216,13 @@ }; | ||
// This is like writeSelection but assumes no parent entity exists | ||
const writeRoot = (ctx: Context, data: Data, select: SelectionSet) => { | ||
forEachFieldNode(ctx, select, node => { | ||
const fieldValue = data[getFieldAlias(node)]; | ||
const writeRoot = ( | ||
ctx: Context, | ||
typename: string, | ||
select: SelectionSet, | ||
data: Data | ||
) => { | ||
forEachFieldNode(typename, typename, select, ctx, node => { | ||
const fieldName = getName(node); | ||
const fieldAlias = getFieldAlias(node); | ||
const fieldArgs = getFieldArguments(node, ctx.variables); | ||
const fieldValue = data[fieldAlias]; | ||
@@ -146,2 +237,11 @@ if ( | ||
} | ||
if (typename === 'Mutation') { | ||
// We run side-effect updates after the default, normalized updates | ||
// so that the data is already available in-store if necessary | ||
const updater = ctx.store.updates[fieldName]; | ||
if (updater !== undefined) { | ||
updater(data, fieldArgs || {}, ctx.store, ctx); | ||
} | ||
} | ||
}); | ||
@@ -153,3 +253,3 @@ }; | ||
ctx: Context, | ||
data: Data | Data[] | null, | ||
data: null | Data | NullArray<Data>, | ||
select: SelectionSet | ||
@@ -166,3 +266,3 @@ ) => { | ||
if (entityKey !== null) { | ||
writeEntity(ctx, entityKey, data, select); | ||
writeSelection(ctx, entityKey, select, data); | ||
} | ||
@@ -174,3 +274,3 @@ }; | ||
// doesn't have a __typename field | ||
const isScalar = (x: Scalar | Data | Array<Scalar | Data>): x is Scalar => { | ||
const isScalar = (x: any): x is Scalar | Scalar[] => { | ||
if (Array.isArray(x)) { | ||
@@ -177,0 +277,0 @@ return x.some(isScalar); |
@@ -1,63 +0,179 @@ | ||
import { Entity, Link, Links, Records } from '../types'; | ||
import { assignObjectToMap, objectOfMap } from './utils'; | ||
import { DocumentNode } from 'graphql'; | ||
import * as Pessimism from 'pessimism'; | ||
export interface Store { | ||
records: Records; | ||
links: Links; | ||
} | ||
import { | ||
EntityField, | ||
Link, | ||
ResolverConfig, | ||
DataField, | ||
SystemFields, | ||
Variables, | ||
Data, | ||
UpdatesConfig, | ||
OptimisticMutationConfig, | ||
} from '../types'; | ||
export interface SerializedStore { | ||
records: { [link: string]: Entity }; | ||
links: { [link: string]: Link }; | ||
import { keyOfEntity, joinKeys, keyOfField } from '../helpers'; | ||
import { query, write, writeFragment } from '../operations'; | ||
interface Ref<T> { | ||
current: null | T; | ||
} | ||
/** Creates a new Store with an optional initial, serialized state */ | ||
export const create = (initial?: SerializedStore): Store => { | ||
const records: Records = new Map(); | ||
const links: Links = new Map(); | ||
const currentDependencies: Ref<Set<string>> = { current: null }; | ||
const currentOptimisticKey: Ref<number> = { current: null }; | ||
if (initial !== undefined) { | ||
assignObjectToMap(records, initial.records); | ||
assignObjectToMap(links, initial.links); | ||
// Resolve a ref value or throw when we're outside of a store run | ||
const refValue = <T>(ref: Ref<T>): T => { | ||
if (ref.current === null) { | ||
// TODO: Add invariant and warning with some production transpilation | ||
throw new Error( | ||
'The cache may only be mutated during operations or as part of its config.' | ||
); | ||
} | ||
return { records, links }; | ||
return ref.current; | ||
}; | ||
/** Serializes a given Store to a plain JSON structure */ | ||
export const serialize = (store: Store): SerializedStore => { | ||
const records = objectOfMap(store.records); | ||
const links = objectOfMap(store.links); | ||
return { records, links }; | ||
// Initialise a store run by resetting its internal state | ||
export const initStoreState = (optimisticKey: null | number) => { | ||
currentDependencies.current = new Set(); | ||
currentOptimisticKey.current = optimisticKey; | ||
}; | ||
export const find = (store: Store, key: string): Entity | null => { | ||
const entity = store.records.get(key); | ||
return entity !== undefined ? entity : null; | ||
// Finalise a store run by clearing its internal state | ||
export const clearStoreState = () => { | ||
currentDependencies.current = null; | ||
currentOptimisticKey.current = null; | ||
}; | ||
export const findOrCreate = (store: Store, key: string): Entity => { | ||
const entity = find(store, key); | ||
if (entity !== null) { | ||
return entity; | ||
} | ||
export const getCurrentDependencies = () => refValue(currentDependencies); | ||
const record: Entity = Object.create(null); | ||
store.records.set(key, record); | ||
return record; | ||
// Add a dependency to the internal store state | ||
export const addDependency = (dependency: string) => { | ||
refValue(currentDependencies).add(dependency); | ||
}; | ||
export const remove = (store: Store, key: string) => { | ||
store.records.delete(key); | ||
const mapSet = <T>(map: Pessimism.Map<T>, key: string, value: T) => { | ||
const optimisticKey = refValue(currentOptimisticKey); | ||
return Pessimism.setOptimistic(map, key, value, optimisticKey); | ||
}; | ||
export const setLink = (store: Store, key: string, link: Link): void => { | ||
store.links.set(key, link); | ||
// Used to remove a value from a Map optimistially (possible by setting it to undefined) | ||
const mapRemove = <T>(map: Pessimism.Map<T>, key: string) => { | ||
const optimisticKey = refValue(currentOptimisticKey); | ||
return optimisticKey | ||
? Pessimism.setOptimistic(map, key, undefined, optimisticKey) | ||
: Pessimism.remove(map, key); | ||
}; | ||
export const removeLink = (store: Store, key: string): void => { | ||
store.links.delete(key); | ||
}; | ||
export class Store { | ||
records: Pessimism.Map<EntityField>; | ||
links: Pessimism.Map<Link>; | ||
export const readLink = (store: Store, key: string): void | Link => | ||
store.links.get(key); | ||
resolvers: ResolverConfig; | ||
updates: UpdatesConfig; | ||
optimisticMutations: OptimisticMutationConfig; | ||
constructor( | ||
resolvers?: ResolverConfig, | ||
updates?: UpdatesConfig, | ||
optimisticMutations?: OptimisticMutationConfig | ||
) { | ||
this.records = Pessimism.make(); | ||
this.links = Pessimism.make(); | ||
this.resolvers = resolvers || {}; | ||
this.updates = updates || {}; | ||
this.optimisticMutations = optimisticMutations || {}; | ||
} | ||
clearOptimistic(optimisticKey: number) { | ||
this.records = Pessimism.clearOptimistic(this.records, optimisticKey); | ||
this.links = Pessimism.clearOptimistic(this.links, optimisticKey); | ||
} | ||
getRecord(fieldKey: string): EntityField { | ||
return Pessimism.get(this.records, fieldKey); | ||
} | ||
removeRecord(fieldKey: string) { | ||
return (this.records = mapRemove(this.records, fieldKey)); | ||
} | ||
writeRecord(field: EntityField, fieldKey: string) { | ||
return (this.records = mapSet(this.records, fieldKey, field)); | ||
} | ||
getField( | ||
entityKey: string, | ||
fieldName: string, | ||
args?: Variables | ||
): EntityField { | ||
const fieldKey = joinKeys(entityKey, keyOfField(fieldName, args)); | ||
return this.getRecord(fieldKey); | ||
} | ||
writeField( | ||
field: EntityField, | ||
entityKey: string, | ||
fieldName: string, | ||
args?: Variables | ||
) { | ||
const fieldKey = joinKeys(entityKey, keyOfField(fieldName, args)); | ||
return (this.records = mapSet(this.records, fieldKey, field)); | ||
} | ||
getLink(key: string): undefined | Link { | ||
return Pessimism.get(this.links, key); | ||
} | ||
removeLink(key: string) { | ||
return (this.links = mapRemove(this.links, key)); | ||
} | ||
writeLink(link: Link, key: string) { | ||
return (this.links = mapSet(this.links, key, link)); | ||
} | ||
resolveValueOrLink(fieldKey: string): DataField { | ||
const fieldValue = this.getRecord(fieldKey); | ||
// Undefined implies a link OR incomplete data. | ||
// A value will imply that we are just fetching a field like date. | ||
if (fieldValue !== undefined) return fieldValue; | ||
// This can be an array OR a string OR undefined again | ||
const link = this.getLink(fieldKey); | ||
return link ? link : null; | ||
} | ||
resolve(entity: SystemFields, field: string, args?: Variables): DataField { | ||
if (typeof entity === 'string') { | ||
addDependency(entity); | ||
return this.resolveValueOrLink(joinKeys(entity, keyOfField(field, args))); | ||
} else { | ||
// This gives us __typename:key | ||
const entityKey = keyOfEntity(entity); | ||
if (entityKey === null) return null; | ||
addDependency(entityKey); | ||
return this.resolveValueOrLink( | ||
joinKeys(entityKey, keyOfField(field, args)) | ||
); | ||
} | ||
} | ||
hasField(key: string): boolean { | ||
return this.getRecord(key) !== undefined || this.getLink(key) !== undefined; | ||
} | ||
updateQuery( | ||
dataQuery: DocumentNode, | ||
updater: (data: Data | null) => Data | ||
): void { | ||
const { data } = query(this, { query: dataQuery }); | ||
write(this, { query: dataQuery }, updater(data)); | ||
} | ||
writeFragment(dataFragment: DocumentNode, data: Data): void { | ||
writeFragment(this, dataFragment, data); | ||
} | ||
} |
@@ -1,6 +0,23 @@ | ||
/** A scalar is any fieldValue without a type. It can also include lists of scalars and embedded objects, which are simply represented as empty object type. */ | ||
export type Scalar = {} | string | number | null; | ||
import { DocumentNode, FragmentDefinitionNode, SelectionNode } from 'graphql'; | ||
import { Store } from './store'; | ||
export type NullPrototype = { [K in keyof ObjectConstructor]: never }; | ||
// Helper types | ||
export type NullArray<T> = Array<null | T>; | ||
// GraphQL helper types | ||
export type SelectionSet = ReadonlyArray<SelectionNode>; | ||
export interface Fragments { | ||
[fragmentName: string]: void | FragmentDefinitionNode; | ||
} | ||
// Scalar types are not entities as part of response data | ||
export type Primitive = null | number | boolean | string; | ||
export interface ScalarObject { | ||
__typename?: never; | ||
[key: string]: any; | ||
} | ||
export type Scalar = Primitive | ScalarObject; | ||
export interface SystemFields { | ||
@@ -12,17 +29,64 @@ __typename: string; | ||
export interface EntityFields { | ||
[fieldName: string]: Scalar; | ||
export type EntityField = undefined | Scalar | Scalar[]; | ||
export type DataField = Scalar | Scalar[] | Data | NullArray<Data>; | ||
export interface DataFields { | ||
[fieldName: string]: DataField; | ||
} | ||
/** Every Entity must have a typeName. It might have some ID fields of which `id` and `_id` are recognised by default. Every other fieldValue is a scalar. */ | ||
export type Entity = NullPrototype & SystemFields & EntityFields; | ||
export type Data = SystemFields & DataFields; | ||
export type Link<Key = string> = null | Key | NullArray<Key>; | ||
export type ResolvedLink = Link<Data>; | ||
/** A link is a key or array of keys referencing other entities in the Records Map. It may be or contain `null`. */ | ||
export type Link = null | string | Array<string | null>; | ||
export interface Variables { | ||
[name: string]: Scalar | Scalar[] | Variables | NullArray<Variables>; | ||
} | ||
/** A link can be resolved into the entities it points to. The resulting structure is a ResolvedLink */ | ||
export type ResolvedLink = null | Entity | Array<Entity | null>; | ||
// This is an input operation | ||
export interface OperationRequest { | ||
query: DocumentNode; | ||
variables?: object; | ||
} | ||
export type Records = Map<string, Entity>; | ||
export type Links = Map<string, Link>; | ||
export type Embedded = Map<string, Scalar>; | ||
export interface ResolveInfo { | ||
fragments: Fragments; | ||
variables: Variables; | ||
} | ||
// Cache resolvers are user-defined to overwrite an entity field result | ||
export type Resolver = ( | ||
parent: Data, | ||
args: Variables, | ||
cache: Store, | ||
info: ResolveInfo | ||
) => DataField; | ||
export interface ResolverConfig { | ||
[typeName: string]: { | ||
[fieldName: string]: Resolver; | ||
}; | ||
} | ||
export type UpdateResolver<T = any> = ( | ||
result: T, | ||
args: Variables, | ||
cache: Store, | ||
info: ResolveInfo | ||
) => void; | ||
export interface UpdatesConfig { | ||
[mutationFieldName: string]: UpdateResolver; | ||
} | ||
export type OptimisticMutationResolver = ( | ||
vars: Variables, | ||
cache: Store, | ||
info: ResolveInfo | ||
) => null | Data | NullArray<Data>; | ||
export interface OptimisticMutationConfig { | ||
[mutationFieldName: string]: OptimisticMutationResolver; | ||
} | ||
// Completeness of the query result | ||
export type Completeness = 'EMPTY' | 'PARTIAL' | 'FULL'; |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No README
QualityPackage does not have a README. This may indicate a failed publish or a low quality package.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
349322
3790
0
0
106
5
49
42
9
1
+ Addedpessimism@^1.0.1
+ Added@0no-co/graphql.web@1.0.11(transitive)
+ Added@urql/core@5.0.8(transitive)
+ Addedpessimism@1.1.4(transitive)
+ Addedurql@4.2.1(transitive)
+ Addedwonka@3.2.26.3.4(transitive)
- Removed@graphql-typed-document-node/core@3.2.0(transitive)
- Removed@urql/core@1.16.2(transitive)
- Removedbs-platform@9.0.2(transitive)
- Removedbs-rebel@0.2.3(transitive)
- Removedurql@1.11.6(transitive)
- Removedwonka@2.0.24.0.15(transitive)
Updatedwonka@^3.2.0