diff --git a/src/ast/index.ts b/src/ast/index.ts index eed05b4..d6ee03c 100644 --- a/src/ast/index.ts +++ b/src/ast/index.ts @@ -1,3 +1,4 @@ export { getFieldArguments, normalizeVariables } from './variables'; +export { SchemaPredicates } from './schemaPredicates'; export * from './traversal'; export * from './node'; diff --git a/src/ast/variables.ts b/src/ast/variables.ts index 8f823c1..8748e48 100644 --- a/src/ast/variables.ts +++ b/src/ast/variables.ts @@ -4,8 +4,8 @@ import { valueFromASTUntyped, } from 'graphql'; -import { makeDict } from '../helpers/dict'; import { getName } from './node'; +import { makeDict } from '../store'; import { Variables } from '../types'; /** Evaluates a fields arguments taking vars into account */ diff --git a/src/cacheExchange.ts b/src/cacheExchange.ts index d79fc85..2432be7 100644 --- a/src/cacheExchange.ts +++ b/src/cacheExchange.ts @@ -10,9 +10,8 @@ import { import { IntrospectionQuery } from 'graphql'; import { filter, map, merge, pipe, share, tap } from 'wonka'; import { query, write, writeOptimistic } from './operations'; -import { SchemaPredicates } from './ast/schemaPredicates'; -import { makeDict } from './helpers/dict'; -import { Store } from './store'; +import { SchemaPredicates } from './ast'; +import { makeDict, Store, clearOptimistic } from './store'; import { UpdatesConfig, @@ -208,7 +207,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ const { key } = operation; if (optimisticKeys.has(key)) { optimisticKeys.delete(key); - store.clearOptimistic(key); + clearOptimistic(store.data, key); } let writeDependencies: Set | void; diff --git a/src/helpers/data.test.ts b/src/helpers/data.test.ts deleted file mode 100644 index a6dd252..0000000 --- a/src/helpers/data.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import * as InMemoryData from './data'; -import { keyOfField } from './keys'; - -let data: InMemoryData.InMemoryData; - -beforeEach(() => { - data = InMemoryData.make(); -}); - -describe('garbage collection', () => { - it('erases orphaned entities', () => { - InMemoryData.writeRecord(data, 'Todo:1', '__typename', 'Todo'); - InMemoryData.writeRecord(data, 'Todo:1', 'id', '1'); - InMemoryData.writeRecord(data, 'Query', '__typename', 'Query'); - InMemoryData.writeLink(data, 'Query', 'todo', 'Todo:1'); - - InMemoryData.gc(data); - - expect(InMemoryData.readLink(data, 'Query', 'todo')).toBe('Todo:1'); - - InMemoryData.writeLink(data, 'Query', 'todo', undefined); - InMemoryData.gc(data); - - expect(InMemoryData.readLink(data, 'Query', 'todo')).toBe(undefined); - expect(InMemoryData.readRecord(data, 'Todo:1', 'id')).toBe(undefined); - }); - - it('keeps readopted entities', () => { - InMemoryData.writeRecord(data, 'Todo:1', '__typename', 'Todo'); - InMemoryData.writeRecord(data, 'Todo:1', 'id', '1'); - InMemoryData.writeRecord(data, 'Query', '__typename', 'Query'); - InMemoryData.writeLink(data, 'Query', 'todo', 'Todo:1'); - InMemoryData.writeLink(data, 'Query', 'todo', undefined); - InMemoryData.writeLink(data, 'Query', 'newTodo', 'Todo:1'); - - InMemoryData.gc(data); - expect(InMemoryData.readLink(data, 'Query', 'newTodo')).toBe('Todo:1'); - expect(InMemoryData.readLink(data, 'Query', 'todo')).toBe(undefined); - expect(InMemoryData.readRecord(data, 'Todo:1', 'id')).toBe('1'); - }); - - it('keeps entities with multiple owners', () => { - InMemoryData.writeRecord(data, 'Todo:1', '__typename', 'Todo'); - InMemoryData.writeRecord(data, 'Todo:1', 'id', '1'); - InMemoryData.writeRecord(data, 'Query', '__typename', 'Query'); - InMemoryData.writeLink(data, 'Query', 'todoA', 'Todo:1'); - InMemoryData.writeLink(data, 'Query', 'todoB', 'Todo:1'); - InMemoryData.writeLink(data, 'Query', 'todoA', undefined); - - InMemoryData.gc(data); - expect(InMemoryData.readLink(data, 'Query', 'todoA')).toBe(undefined); - expect(InMemoryData.readLink(data, 'Query', 'todoB')).toBe('Todo:1'); - expect(InMemoryData.readRecord(data, 'Todo:1', 'id')).toBe('1'); - }); - - it('skips entities with optimistic updates', () => { - InMemoryData.writeRecord(data, 'Todo:1', '__typename', 'Todo'); - InMemoryData.writeRecord(data, 'Todo:1', 'id', '1'); - InMemoryData.writeLink(data, 'Query', 'todo', 'Todo:1'); - - InMemoryData.setCurrentOptimisticKey(1); - InMemoryData.writeLink(data, 'Query', 'temp', 'Todo:1'); - InMemoryData.setCurrentOptimisticKey(0); - - InMemoryData.writeLink(data, 'Query', 'todo', undefined); - InMemoryData.gc(data); - - expect(InMemoryData.readRecord(data, 'Todo:1', 'id')).toBe('1'); - - InMemoryData.clearOptimistic(data, 1); - InMemoryData.gc(data); - expect(InMemoryData.readRecord(data, 'Todo:1', 'id')).toBe(undefined); - }); - - it('erases child entities that are orphaned', () => { - InMemoryData.writeRecord(data, 'Author:1', '__typename', 'Author'); - InMemoryData.writeRecord(data, 'Author:1', 'id', '1'); - InMemoryData.writeLink(data, 'Todo:1', 'author', 'Author:1'); - InMemoryData.writeRecord(data, 'Todo:1', '__typename', 'Todo'); - InMemoryData.writeRecord(data, 'Todo:1', 'id', '1'); - InMemoryData.writeLink(data, 'Query', 'todo', 'Todo:1'); - - InMemoryData.writeLink(data, 'Query', 'todo', undefined); - InMemoryData.gc(data); - - expect(InMemoryData.readRecord(data, 'Todo:1', 'id')).toBe(undefined); - expect(InMemoryData.readRecord(data, 'Author:1', 'id')).toBe(undefined); - }); -}); - -describe('inspectFields', () => { - it('returns field infos for all links and records', () => { - InMemoryData.writeRecord(data, 'Query', '__typename', 'Query'); - InMemoryData.writeLink( - data, - 'Query', - keyOfField('todo', { id: '1' }), - 'Todo:1' - ); - InMemoryData.writeRecord( - data, - 'Query', - keyOfField('hasTodo', { id: '1' }), - true - ); - InMemoryData.writeLink(data, 'Query', 'randomTodo', 'Todo:1'); - - expect(InMemoryData.inspectFields(data, 'Query')).toMatchInlineSnapshot(` - Array [ - Object { - "arguments": Object { - "id": "1", - }, - "fieldKey": "todo({\\"id\\":\\"1\\"})", - "fieldName": "todo", - }, - Object { - "arguments": null, - "fieldKey": "randomTodo", - "fieldName": "randomTodo", - }, - Object { - "arguments": null, - "fieldKey": "__typename", - "fieldName": "__typename", - }, - Object { - "arguments": Object { - "id": "1", - }, - "fieldKey": "hasTodo({\\"id\\":\\"1\\"})", - "fieldName": "hasTodo", - }, - ] - `); - }); - - it('returns an empty array when an entity is unknown', () => { - expect(InMemoryData.inspectFields(data, 'Random')).toEqual([]); - }); - - it('returns field infos for all optimistic updates', () => { - InMemoryData.setCurrentOptimisticKey(1); - InMemoryData.writeLink(data, 'Query', 'todo', 'Todo:1'); - InMemoryData.setCurrentOptimisticKey(0); - - expect(InMemoryData.inspectFields(data, 'Random')).toMatchInlineSnapshot( - `Array []` - ); - }); - - it('avoids duplicate field infos', () => { - InMemoryData.writeLink(data, 'Query', 'todo', 'Todo:1'); - - InMemoryData.setCurrentOptimisticKey(1); - InMemoryData.writeLink(data, 'Query', 'todo', 'Todo:2'); - InMemoryData.setCurrentOptimisticKey(0); - - expect(InMemoryData.inspectFields(data, 'Query')).toMatchInlineSnapshot(` - Array [ - Object { - "arguments": null, - "fieldKey": "todo", - "fieldName": "todo", - }, - ] - `); - }); -}); diff --git a/src/helpers/dict.ts b/src/helpers/dict.ts deleted file mode 100644 index b9b5234..0000000 --- a/src/helpers/dict.ts +++ /dev/null @@ -1 +0,0 @@ -export const makeDict = (): any => Object.create(null); diff --git a/src/helpers/index.ts b/src/helpers/index.ts deleted file mode 100644 index d108348..0000000 --- a/src/helpers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './keys'; -export * from './timing'; diff --git a/src/index.ts b/src/index.ts index 0365305..fc0f3f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './operations'; export * from './types'; -export { Store } from './store'; +export { Store, initDataState, clearDataState } from './store'; export { cacheExchange } from './cacheExchange'; export { populateExchange } from './populateExchange'; diff --git a/src/operations/invalidate.test.ts b/src/operations/invalidate.test.ts index b4e6b7b..694d922 100644 --- a/src/operations/invalidate.test.ts +++ b/src/operations/invalidate.test.ts @@ -1,7 +1,8 @@ -import { Store, initStoreState, clearStoreState } from '../store'; import gql from 'graphql-tag'; import { write } from './write'; import { invalidate } from './invalidate'; +import * as InMemoryData from '../store/data'; +import { Store } from '../store'; import { SchemaPredicates } from '../ast/schemaPredicates'; const TODO_QUERY = gql` @@ -48,11 +49,7 @@ describe('Query', () => { ); spy.console = jest.spyOn(console, 'warn'); - initStoreState(store, 0); - }); - - afterEach(() => { - clearStoreState(); + InMemoryData.initDataState(store.data, 0); }); it('should warn once for invalid fields on an entity', () => { diff --git a/src/operations/invalidate.ts b/src/operations/invalidate.ts index b8d2905..80e10a0 100644 --- a/src/operations/invalidate.ts +++ b/src/operations/invalidate.ts @@ -17,10 +17,10 @@ import { SelectionSet, } from '../types'; -import { Store, addDependency } from '../store'; +import * as InMemoryData from '../store/data'; +import { Store, keyOfField } from '../store'; +import { SchemaPredicates } from '../ast'; import { SelectionIterator } from './shared'; -import { joinKeys, keyOfField } from '../helpers'; -import { SchemaPredicates } from '../ast/schemaPredicates'; interface Context { store: Store; @@ -51,17 +51,15 @@ export const invalidateSelection = ( entityKey: string, select: SelectionSet ) => { - const { store } = ctx; const isQuery = entityKey === 'Query'; let typename: EntityField; if (!isQuery) { - addDependency(entityKey); - typename = store.getField(entityKey, '__typename'); + typename = InMemoryData.readRecord(entityKey, '__typename'); if (typeof typename !== 'string') { return; } else { - store.writeRecord(undefined, entityKey, keyOfField('__typename')); + InMemoryData.writeRecord(entityKey, '__typename', undefined); } } else { typename = entityKey; @@ -76,7 +74,6 @@ export const invalidateSelection = ( fieldName, getFieldArguments(node, ctx.variables) ); - const key = joinKeys(entityKey, fieldKey); if ( process.env.NODE_ENV !== 'production' && @@ -86,15 +83,14 @@ export const invalidateSelection = ( ctx.schemaPredicates.isFieldAvailableOnType(typename, fieldName); } - if (isQuery) addDependency(key); - if (node.selectionSet === undefined) { - store.writeRecord(undefined, entityKey, fieldKey); + InMemoryData.writeRecord(entityKey, fieldKey, undefined); } else { const fieldSelect = getSelectionSet(node); - const link = store.getLink(entityKey, fieldKey); - store.writeLink(undefined, entityKey, fieldKey); - store.writeRecord(undefined, entityKey, fieldKey); + const link = InMemoryData.readLink(entityKey, fieldKey); + + InMemoryData.writeLink(entityKey, fieldKey, undefined); + InMemoryData.writeRecord(entityKey, fieldKey, undefined); if (Array.isArray(link)) { for (let i = 0, l = link.length; i < l; i++) { diff --git a/src/operations/query.ts b/src/operations/query.ts index a084b6c..ac46041 100644 --- a/src/operations/query.ts +++ b/src/operations/query.ts @@ -1,3 +1,5 @@ +import { FieldNode, DocumentNode, FragmentDefinitionNode } from 'graphql'; + import { getFragments, getMainOperation, @@ -22,18 +24,18 @@ import { import { Store, - addDependency, getCurrentDependencies, - initStoreState, - clearStoreState, + initDataState, + clearDataState, + makeDict, + joinKeys, + keyOfField, } from '../store'; +import * as InMemoryData from '../store/data'; import { warn, pushDebugNode } from '../helpers/help'; -import { makeDict } from '../helpers/dict'; -import { joinKeys, keyOfField } from '../helpers'; import { SelectionIterator, isScalar } from './shared'; -import { SchemaPredicates } from '../ast/schemaPredicates'; -import { FieldNode, DocumentNode, FragmentDefinitionNode } from 'graphql'; +import { SchemaPredicates } from '../ast'; export interface QueryResult { dependencies: Set; @@ -58,9 +60,9 @@ export const query = ( request: OperationRequest, data?: Data ): QueryResult => { - initStoreState(store, 0); + initDataState(store.data, 0); const result = read(store, request, data); - clearStoreState(); + clearDataState(); return result; }; @@ -232,12 +234,11 @@ const readSelection = ( ): Data | undefined => { const { store, schemaPredicates } = ctx; const isQuery = entityKey === store.getRootKey('query'); - if (!isQuery) addDependency(entityKey); // Get the __typename field for a given entity to check that it exists - const typename = isQuery - ? entityKey - : store.getField(entityKey, '__typename'); + const typename = !isQuery + ? InMemoryData.readRecord(entityKey, '__typename') + : entityKey; if (typeof typename !== 'string') { return undefined; } @@ -254,11 +255,9 @@ const readSelection = ( const fieldArgs = getFieldArguments(node, ctx.variables); const fieldAlias = getFieldAlias(node); const fieldKey = keyOfField(fieldName, fieldArgs); - const fieldValue = store.getRecord(entityKey, fieldKey); + const fieldValue = InMemoryData.readRecord(entityKey, fieldKey); const key = joinKeys(entityKey, fieldKey); - if (isQuery) addDependency(key); - if (process.env.NODE_ENV !== 'production' && schemaPredicates && typename) { schemaPredicates.isFieldAvailableOnType(typename, fieldName); } @@ -314,7 +313,7 @@ const readSelection = ( dataFieldValue = fieldValue; } else { // We have a selection set which means that we'll be checking for links - const link = store.getLink(entityKey, fieldKey); + const link = InMemoryData.readLink(entityKey, fieldKey); if (link !== undefined) { dataFieldValue = resolveLink( ctx, @@ -365,10 +364,9 @@ const readResolverResult = ( ): Data | undefined => { const { store, schemaPredicates } = ctx; const entityKey = store.keyOfEntity(result) || key; - addDependency(entityKey); - const resolvedTypename = result.__typename; - const typename = store.getField(entityKey, '__typename') || resolvedTypename; + const typename = + InMemoryData.readRecord(entityKey, '__typename') || resolvedTypename; if ( typeof typename !== 'string' || @@ -403,7 +401,7 @@ const readResolverResult = ( getFieldArguments(node, ctx.variables) ); const key = joinKeys(entityKey, fieldKey); - const fieldValue = store.getRecord(entityKey, fieldKey); + const fieldValue = InMemoryData.readRecord(entityKey, fieldKey); const resultValue = result[fieldName]; if (process.env.NODE_ENV !== 'production' && schemaPredicates && typename) { @@ -432,7 +430,7 @@ const readResolverResult = ( ); } else { // Otherwise we attempt to get the missing field from the cache - const link = store.getLink(entityKey, fieldKey); + const link = InMemoryData.readLink(entityKey, fieldKey); if (link !== undefined) { dataFieldValue = resolveLink( diff --git a/src/operations/shared.ts b/src/operations/shared.ts index f9fbce0..fa30588 100644 --- a/src/operations/shared.ts +++ b/src/operations/shared.ts @@ -1,11 +1,12 @@ import { FieldNode, InlineFragmentNode, FragmentDefinitionNode } from 'graphql'; -import { Fragments, Variables, SelectionSet, Scalar } from '../types'; -import { Store } from '../store'; -import { keyOfField } from '../helpers'; + import { warn, pushDebugNode } from '../helpers/help'; -import { SchemaPredicates } from '../ast/schemaPredicates'; +import { hasField } from '../store/data'; +import { Store, keyOfField } from '../store'; +import { Fragments, Variables, SelectionSet, Scalar } from '../types'; import { + SchemaPredicates, getTypeCondition, getFieldArguments, shouldInclude, @@ -52,7 +53,7 @@ const isFragmentHeuristicallyMatching = ( getName(node), getFieldArguments(node, ctx.variables) ); - return !ctx.store.hasField(entityKey, fieldKey); + return !hasField(entityKey, fieldKey); }); }; diff --git a/src/operations/write.test.ts b/src/operations/write.test.ts index 9c0a67d..362cb88 100644 --- a/src/operations/write.test.ts +++ b/src/operations/write.test.ts @@ -1,7 +1,8 @@ -import { Store } from '../store'; import gql from 'graphql-tag'; import { write } from './write'; -import { SchemaPredicates } from '../ast/schemaPredicates'; +import * as InMemoryData from '../store/data'; +import { Store } from '../store'; +import { SchemaPredicates } from '../ast'; const TODO_QUERY = gql` query todos { @@ -151,7 +152,9 @@ describe('Query', () => { // Because of us writing an undefined field expect(console.warn).toHaveBeenCalledTimes(1); expect((console.warn as any).mock.calls[0][0]).toMatch(/undefined/); + + InMemoryData.initDataState(store.data, 0); // The field must still be `'test'` - expect(store.getRecord('Query', 'field')).toBe('test'); + expect(InMemoryData.readRecord('Query', 'field')).toBe('test'); }); }); diff --git a/src/operations/write.ts b/src/operations/write.ts index a443044..affed3e 100644 --- a/src/operations/write.ts +++ b/src/operations/write.ts @@ -9,6 +9,7 @@ import { getFragmentTypeName, getName, getFieldArguments, + SchemaPredicates, } from '../ast'; import { @@ -23,17 +24,17 @@ import { import { Store, - addDependency, getCurrentDependencies, - initStoreState, - clearStoreState, + initDataState, + clearDataState, + makeDict, + joinKeys, + keyOfField, } from '../store'; +import * as InMemoryData from '../store/data'; import { invariant, warn, pushDebugNode } from '../helpers/help'; -import { makeDict } from '../helpers/dict'; import { SelectionIterator, isScalar } from './shared'; -import { joinKeys, keyOfField } from '../helpers'; -import { SchemaPredicates } from '../ast/schemaPredicates'; export interface WriteResult { dependencies: Set; @@ -58,9 +59,9 @@ export const write = ( request: OperationRequest, data: Data ): WriteResult => { - initStoreState(store, 0); + initDataState(store.data, 0); const result = startWrite(store, request, data); - clearStoreState(); + clearDataState(); return result; }; @@ -105,7 +106,7 @@ export const writeOptimistic = ( request: OperationRequest, optimisticKey: number ): WriteResult => { - initStoreState(store, optimisticKey); + initDataState(store.data, optimisticKey); const operation = getMainOperation(request.query); const result: WriteResult = { dependencies: getCurrentDependencies() }; @@ -170,7 +171,7 @@ export const writeOptimistic = ( } } - clearStoreState(); + clearDataState(); return result; }; @@ -229,12 +230,14 @@ const writeSelection = ( select: SelectionSet, data: Data ) => { - const { store } = ctx; const isQuery = entityKey === ctx.store.getRootKey('query'); const typename = data.__typename; - if (!isQuery) addDependency(entityKey); - store.writeField(isQuery ? entityKey : typename, entityKey, '__typename'); + InMemoryData.writeRecord( + entityKey, + '__typename', + isQuery ? entityKey : typename + ); const iter = new SelectionIterator(typename, entityKey, select, ctx); @@ -246,8 +249,6 @@ const writeSelection = ( const fieldValue = data[getFieldAlias(node)]; const key = joinKeys(entityKey, fieldKey); - if (isQuery) addDependency(key); - if (process.env.NODE_ENV !== 'production') { if (fieldValue === undefined) { const advice = ctx.optimistic @@ -277,12 +278,12 @@ const writeSelection = ( if (node.selectionSet === undefined) { // This is a leaf node, so we're setting the field's value directly - store.writeRecord(fieldValue, entityKey, fieldKey); + InMemoryData.writeRecord(entityKey, fieldKey, fieldValue); } else if (!isScalar(fieldValue)) { // Process the field and write links for the child entities that have been written const link = writeField(ctx, key, getSelectionSet(node), fieldValue); - store.writeLink(link, entityKey, fieldKey); - store.writeRecord(undefined, entityKey, fieldKey); + InMemoryData.writeLink(entityKey, fieldKey, link); + InMemoryData.writeRecord(entityKey, fieldKey, undefined); } else { warn( 'Invalid value: The field at `' + @@ -294,7 +295,7 @@ const writeSelection = ( ); // This is a rare case for invalid entities - store.writeRecord(fieldValue, entityKey, fieldKey); + InMemoryData.writeRecord(entityKey, fieldKey, fieldValue); } } }; diff --git a/src/populateExchange.ts b/src/populateExchange.ts index 0cdee33..a3f9134 100644 --- a/src/populateExchange.ts +++ b/src/populateExchange.ts @@ -21,8 +21,8 @@ import { pipe, tap, map } from 'wonka'; import { Exchange, Operation } from 'urql/core'; import { getName, getSelectionSet, unwrapType } from './ast'; +import { makeDict } from './store'; import { invariant, warn } from './helpers/help'; -import { makeDict } from './helpers/dict'; interface PopulateExchangeOpts { schema: IntrospectionQuery; diff --git a/src/store/data.test.ts b/src/store/data.test.ts new file mode 100644 index 0000000..243c496 --- /dev/null +++ b/src/store/data.test.ts @@ -0,0 +1,197 @@ +import * as InMemoryData from './data'; +import { keyOfField } from './keys'; + +let data: InMemoryData.InMemoryData; + +beforeEach(() => { + data = InMemoryData.make('Query'); + InMemoryData.initDataState(data, null); +}); + +describe('garbage collection', () => { + it('erases orphaned entities', () => { + InMemoryData.writeRecord('Todo:1', '__typename', 'Todo'); + InMemoryData.writeRecord('Todo:1', 'id', '1'); + InMemoryData.writeRecord('Query', '__typename', 'Query'); + InMemoryData.writeLink('Query', 'todo', 'Todo:1'); + + InMemoryData.gc(data); + + expect(InMemoryData.readLink('Query', 'todo')).toBe('Todo:1'); + + InMemoryData.writeLink('Query', 'todo', undefined); + InMemoryData.gc(data); + + expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined); + expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined); + + expect([...InMemoryData.getCurrentDependencies()]).toEqual([ + 'Todo:1', + 'Query.todo', + ]); + }); + + it('keeps readopted entities', () => { + InMemoryData.writeRecord('Todo:1', '__typename', 'Todo'); + InMemoryData.writeRecord('Todo:1', 'id', '1'); + InMemoryData.writeRecord('Query', '__typename', 'Query'); + InMemoryData.writeLink('Query', 'todo', 'Todo:1'); + InMemoryData.writeLink('Query', 'todo', undefined); + InMemoryData.writeLink('Query', 'newTodo', 'Todo:1'); + + InMemoryData.gc(data); + + expect(InMemoryData.readLink('Query', 'newTodo')).toBe('Todo:1'); + expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined); + expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1'); + + expect([...InMemoryData.getCurrentDependencies()]).toEqual([ + 'Todo:1', + 'Query.todo', + 'Query.newTodo', + ]); + }); + + it('keeps entities with multiple owners', () => { + InMemoryData.writeRecord('Todo:1', '__typename', 'Todo'); + InMemoryData.writeRecord('Todo:1', 'id', '1'); + InMemoryData.writeRecord('Query', '__typename', 'Query'); + InMemoryData.writeLink('Query', 'todoA', 'Todo:1'); + InMemoryData.writeLink('Query', 'todoB', 'Todo:1'); + InMemoryData.writeLink('Query', 'todoA', undefined); + + InMemoryData.gc(data); + + expect(InMemoryData.readLink('Query', 'todoA')).toBe(undefined); + expect(InMemoryData.readLink('Query', 'todoB')).toBe('Todo:1'); + expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1'); + + expect([...InMemoryData.getCurrentDependencies()]).toEqual([ + 'Todo:1', + 'Query.todoA', + 'Query.todoB', + ]); + }); + + it('skips entities with optimistic updates', () => { + InMemoryData.writeRecord('Todo:1', '__typename', 'Todo'); + InMemoryData.writeRecord('Todo:1', 'id', '1'); + InMemoryData.writeLink('Query', 'todo', 'Todo:1'); + + InMemoryData.initDataState(data, 1); + InMemoryData.writeLink('Query', 'temp', 'Todo:1'); + InMemoryData.initDataState(data, 0); + + InMemoryData.writeLink('Query', 'todo', undefined); + InMemoryData.gc(data); + + expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1'); + + InMemoryData.clearOptimistic(data, 1); + InMemoryData.gc(data); + expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined); + + expect([...InMemoryData.getCurrentDependencies()]).toEqual([ + 'Query.todo', + 'Todo:1', + ]); + }); + + it('erases child entities that are orphaned', () => { + InMemoryData.writeRecord('Author:1', '__typename', 'Author'); + InMemoryData.writeRecord('Author:1', 'id', '1'); + InMemoryData.writeLink('Todo:1', 'author', 'Author:1'); + InMemoryData.writeRecord('Todo:1', '__typename', 'Todo'); + InMemoryData.writeRecord('Todo:1', 'id', '1'); + InMemoryData.writeLink('Query', 'todo', 'Todo:1'); + + InMemoryData.writeLink('Query', 'todo', undefined); + InMemoryData.gc(data); + + expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined); + expect(InMemoryData.readRecord('Author:1', 'id')).toBe(undefined); + + expect([...InMemoryData.getCurrentDependencies()]).toEqual([ + 'Author:1', + 'Todo:1', + 'Query.todo', + ]); + }); +}); + +describe('inspectFields', () => { + it('returns field infos for all links and records', () => { + InMemoryData.writeRecord('Query', '__typename', 'Query'); + InMemoryData.writeLink('Query', keyOfField('todo', { id: '1' }), 'Todo:1'); + InMemoryData.writeRecord('Query', keyOfField('hasTodo', { id: '1' }), true); + + InMemoryData.writeLink('Query', 'randomTodo', 'Todo:1'); + + expect(InMemoryData.inspectFields('Query')).toMatchInlineSnapshot(` + Array [ + Object { + "arguments": Object { + "id": "1", + }, + "fieldKey": "todo({\\"id\\":\\"1\\"})", + "fieldName": "todo", + }, + Object { + "arguments": null, + "fieldKey": "randomTodo", + "fieldName": "randomTodo", + }, + Object { + "arguments": null, + "fieldKey": "__typename", + "fieldName": "__typename", + }, + Object { + "arguments": Object { + "id": "1", + }, + "fieldKey": "hasTodo({\\"id\\":\\"1\\"})", + "fieldName": "hasTodo", + }, + ] + `); + + expect([...InMemoryData.getCurrentDependencies()]).toEqual([ + 'Query.todo({"id":"1"})', + 'Query.hasTodo({"id":"1"})', + 'Query.randomTodo', + ]); + }); + + it('returns an empty array when an entity is unknown', () => { + expect(InMemoryData.inspectFields('Random')).toEqual([]); + + expect([...InMemoryData.getCurrentDependencies()]).toEqual(['Random']); + }); + + it('returns field infos for all optimistic updates', () => { + InMemoryData.initDataState(data, 1); + InMemoryData.writeLink('Query', 'todo', 'Todo:1'); + + expect(InMemoryData.inspectFields('Random')).toMatchInlineSnapshot( + `Array []` + ); + }); + + it('avoids duplicate field infos', () => { + InMemoryData.writeLink('Query', 'todo', 'Todo:1'); + + InMemoryData.initDataState(data, 1); + InMemoryData.writeLink('Query', 'todo', 'Todo:2'); + + expect(InMemoryData.inspectFields('Query')).toMatchInlineSnapshot(` + Array [ + Object { + "arguments": null, + "fieldKey": "todo", + "fieldName": "todo", + }, + ] + `); + }); +}); diff --git a/src/helpers/data.ts b/src/store/data.ts similarity index 78% rename from src/helpers/data.ts rename to src/store/data.ts index d7837dc..2001ebb 100644 --- a/src/helpers/data.ts +++ b/src/store/data.ts @@ -1,5 +1,7 @@ import { Link, EntityField, FieldInfo } from '../types'; -import { fieldInfoOfKey } from './keys'; +import { invariant, currentDebugStack } from '../helpers/help'; +import { fieldInfoOfKey, joinKeys } from './keys'; +import { defer } from './timing'; type Dict = Record; type KeyMap = Map; @@ -12,6 +14,8 @@ interface NodeMap { } export interface InMemoryData { + queryRootKey: string; + gcScheduled: boolean; gcBatch: Set; refCount: Dict; refLock: OptimisticMap>; @@ -19,9 +23,11 @@ export interface InMemoryData { links: NodeMap; } +let currentData: null | InMemoryData = null; +let currentDependencies: null | Set = null; let currentOptimisticKey: null | number = null; -const makeDict = (): Dict => Object.create(null); +export const makeDict = (): any => Object.create(null); const makeNodeMap = (): NodeMap => ({ optimistic: makeDict(), @@ -29,11 +35,51 @@ const makeNodeMap = (): NodeMap => ({ keys: [], }); -export const setCurrentOptimisticKey = (optimisticKey: number | null) => { +/** Before reading or writing the global state needs to be initialised */ +export const initDataState = ( + data: InMemoryData, + optimisticKey: number | null +) => { + currentData = data; + currentDependencies = new Set(); currentOptimisticKey = optimisticKey; + if (process.env.NODE_ENV !== 'production') { + currentDebugStack.length = 0; + } +}; + +/** Reset the data state after read/write is complete */ +export const clearDataState = () => { + const data = currentData!; + if (!data.gcScheduled && data.gcBatch.size > 0) { + data.gcScheduled = true; + defer(() => gc(data)); + } + + currentData = null; + currentDependencies = null; + currentOptimisticKey = null; + if (process.env.NODE_ENV !== 'production') { + currentDebugStack.length = 0; + } +}; + +/** As we're writing, we keep around all the records and links we've read or have written to */ +export const getCurrentDependencies = (): Set => { + invariant( + currentDependencies !== null, + 'Invalid Cache call: The cache may only be accessed or mutated during' + + 'operations like write or query, or as part of its resolvers, updaters, ' + + 'or optimistic configs.', + 2 + ); + + return currentDependencies; }; -export const make = (): InMemoryData => ({ +export const make = (queryRootKey: string): InMemoryData => ({ + queryRootKey, + gcScheduled: false, gcBatch: new Set(), refCount: makeDict(), refLock: makeDict(), @@ -187,6 +233,8 @@ const extractNodeMapFields = ( /** Garbage collects all entities that have been marked as having no references */ export const gc = (data: InMemoryData) => { + // Reset gcScheduled flag + data.gcScheduled = false; // Iterate over all entities that have been marked for deletion // Entities have been marked for deletion in `updateRCForEntity` if // their reference count dropped to 0 @@ -228,35 +276,55 @@ export const gc = (data: InMemoryData) => { }); }; +const updateDependencies = (entityKey: string, fieldKey?: string) => { + if (fieldKey !== '__typename') { + if (entityKey !== currentData!.queryRootKey) { + currentDependencies!.add(entityKey); + } else if (fieldKey !== undefined) { + currentDependencies!.add(joinKeys(entityKey, fieldKey)); + } + } +}; + /** Reads an entity's field (a "record") from data */ export const readRecord = ( - data: InMemoryData, entityKey: string, fieldKey: string -): EntityField => getNode(data.records, entityKey, fieldKey); +): EntityField => { + updateDependencies(entityKey, fieldKey); + return getNode(currentData!.records, entityKey, fieldKey); +}; /** Reads an entity's link from data */ export const readLink = ( - data: InMemoryData, entityKey: string, fieldKey: string -): Link | undefined => getNode(data.links, entityKey, fieldKey); +): Link | undefined => { + updateDependencies(entityKey, fieldKey); + return getNode(currentData!.links, entityKey, fieldKey); +}; /** Writes an entity's field (a "record") to data */ export const writeRecord = ( - data: InMemoryData, entityKey: string, fieldKey: string, value: EntityField -) => setNode(data.records, entityKey, fieldKey, value); +) => { + updateDependencies(entityKey, fieldKey); + setNode(currentData!.records, entityKey, fieldKey, value); +}; + +export const hasField = (entityKey: string, fieldKey: string): boolean => + readRecord(entityKey, fieldKey) !== undefined || + readLink(entityKey, fieldKey) !== undefined; /** Writes an entity's link to data */ export const writeLink = ( - data: InMemoryData, entityKey: string, fieldKey: string, link: Link | undefined ) => { + const data = currentData!; // Retrieve the reference counting dict or the optimistic reference locking dict let refCount: Dict; // Retrive the link NodeMap from either an optimistic or the base layer @@ -280,6 +348,8 @@ export const writeLink = ( const prevLinkNode = links !== undefined ? links.get(entityKey) : undefined; const prevLink = prevLinkNode !== undefined ? prevLinkNode[fieldKey] : null; + // Update dependencies + updateDependencies(entityKey, fieldKey); // Update the link setNode(data.links, entityKey, fieldKey, link); // First decrease the reference count for the previous link @@ -297,13 +367,12 @@ export const clearOptimistic = (data: InMemoryData, optimisticKey: number) => { }; /** Return an array of FieldInfo (info on all the fields and their arguments) for a given entity */ -export const inspectFields = ( - data: InMemoryData, - entityKey: string -): FieldInfo[] => { - const { links, records } = data; +export const inspectFields = (entityKey: string): FieldInfo[] => { + const { links, records } = currentData!; const fieldInfos: FieldInfo[] = []; const seenFieldKeys: Set = new Set(); + // Update dependencies + updateDependencies(entityKey); // Extract FieldInfos to the fieldInfos array for links and records // This also deduplicates by keeping track of fieldKeys in the seenFieldKeys Set extractNodeMapFields(fieldInfos, seenFieldKeys, entityKey, links); diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..7b180a0 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,10 @@ +export { + makeDict, + initDataState, + clearDataState, + clearOptimistic, + getCurrentDependencies, +} from './data'; + +export * from './keys'; +export * from './store'; diff --git a/src/helpers/keys.ts b/src/store/keys.ts similarity index 100% rename from src/helpers/keys.ts rename to src/store/keys.ts diff --git a/src/store.test.ts b/src/store/store.test.ts similarity index 82% rename from src/store.test.ts rename to src/store/store.test.ts index d4f3b24..04b1948 100644 --- a/src/store.test.ts +++ b/src/store/store.test.ts @@ -1,15 +1,10 @@ import gql from 'graphql-tag'; -import { - Store, - initStoreState, - clearStoreState, - getCurrentDependencies, -} from './store'; - -import { Data } from './types'; -import { query } from './operations/query'; -import { write, writeOptimistic } from './operations/write'; +import { Data } from '../types'; +import { query } from '../operations/query'; +import { write, writeOptimistic } from '../operations/write'; +import * as InMemoryData from './data'; +import { Store } from './store'; const Appointment = gql` query appointment($id: String) { @@ -91,9 +86,9 @@ describe('Store with OptimisticMutationConfig', () => { }, ], }; - initStoreState(store, null); + InMemoryData.initDataState(store.data, 0); write(store, { query: Todos }, todosData); - initStoreState(store, null); + InMemoryData.initDataState(store.data, null); }); it('Should resolve a property', () => { @@ -107,17 +102,17 @@ describe('Store with OptimisticMutationConfig', () => { const result = store.resolve({ id: 0, __typename: 'Todo' }, 'text'); expect(result).toEqual('Go to the shops'); // TODO: we have no way of asserting this to really be the case. - const deps = getCurrentDependencies(); + const deps = InMemoryData.getCurrentDependencies(); expect(deps).toEqual(new Set(['Todo:0', 'Author:0'])); - clearStoreState(); + InMemoryData.clearDataState(); }); it('should resolve with a key as first argument', () => { const authorResult = store.resolve('Author:0', 'name'); expect(authorResult).toBe('Jovi'); - const deps = getCurrentDependencies(); + const deps = InMemoryData.getCurrentDependencies(); expect(deps).toEqual(new Set(['Author:0'])); - clearStoreState(); + InMemoryData.clearDataState(); }); it('Should resolve a link property', () => { @@ -129,23 +124,25 @@ describe('Store with OptimisticMutationConfig', () => { }; const result = store.resolve(parent, 'author'); expect(result).toEqual('Author:0'); - const deps = getCurrentDependencies(); + const deps = InMemoryData.getCurrentDependencies(); expect(deps).toEqual(new Set(['Todo:0'])); - clearStoreState(); + InMemoryData.clearDataState(); }); it('should be able to invalidate data (one relation key)', () => { let { data } = query(store, { query: Todos }); - expect((data as any).todos).toHaveLength(3); - expect(store.getRecord('Todo:0', 'text')).toBe('Go to the shops'); - initStoreState(store, 0); + InMemoryData.initDataState(store.data, 0); + expect((data as any).todos).toHaveLength(3); + expect(InMemoryData.readRecord('Todo:0', 'text')).toBe('Go to the shops'); store.invalidateQuery(Todos); - clearStoreState(); + InMemoryData.clearDataState(); ({ data } = query(store, { query: Todos })); expect(data).toBe(null); - expect(store.getRecord('Todo:0', 'text')).toBe(undefined); + + InMemoryData.initDataState(store.data, 0); + expect(InMemoryData.readRecord('Todo:0', 'text')).toBe(undefined); }); it('should be able to invalidate data with arguments', () => { @@ -170,20 +167,26 @@ describe('Store with OptimisticMutationConfig', () => { variables: { id: '1' }, }); expect((data as any).appointment.info).toBe('urql meeting'); - expect(store.getRecord('Appointment:1', 'info')).toBe('urql meeting'); - initStoreState(store, 0); + + InMemoryData.initDataState(store.data, 0); + expect(InMemoryData.readRecord('Appointment:1', 'info')).toBe( + 'urql meeting' + ); store.invalidateQuery(Appointment, { id: '1' }); - clearStoreState(); + InMemoryData.clearDataState(); + ({ data } = query(store, { query: Appointment, variables: { id: '1' }, })); expect(data).toBe(null); - expect(store.getRecord('Appointment:1', 'info')).toBe(undefined); + + InMemoryData.initDataState(store.data, 0); + expect(InMemoryData.readRecord('Appointment:1', 'info')).toBe(undefined); }); it('should be able to write a fragment', () => { - initStoreState(store, 0); + InMemoryData.initDataState(store.data, 0); store.writeFragment( gql` @@ -200,7 +203,7 @@ describe('Store with OptimisticMutationConfig', () => { } ); - const deps = getCurrentDependencies(); + const deps = InMemoryData.getCurrentDependencies(); expect(deps).toEqual(new Set(['Todo:0'])); const { data } = query(store, { query: Todos }); @@ -220,7 +223,7 @@ describe('Store with OptimisticMutationConfig', () => { }); it('should be able to read a fragment', () => { - initStoreState(store, 0); + InMemoryData.initDataState(store.data, 0); const result = store.readFragment( gql` fragment _ on Todo { @@ -232,7 +235,7 @@ describe('Store with OptimisticMutationConfig', () => { { id: '0' } ); - const deps = getCurrentDependencies(); + const deps = InMemoryData.getCurrentDependencies(); expect(deps).toEqual(new Set(['Todo:0'])); expect(result).toEqual({ @@ -242,11 +245,11 @@ describe('Store with OptimisticMutationConfig', () => { __typename: 'Todo', }); - clearStoreState(); + InMemoryData.clearDataState(); }); it('should be able to update a query', () => { - initStoreState(store, 0); + InMemoryData.initDataState(store.data, 0); store.updateQuery({ query: Todos }, data => ({ ...data, todos: [ @@ -263,7 +266,7 @@ describe('Store with OptimisticMutationConfig', () => { }, ], })); - clearStoreState(); + InMemoryData.clearDataState(); const { data: result } = query(store, { query: Todos, @@ -304,7 +307,7 @@ describe('Store with OptimisticMutationConfig', () => { } ); - initStoreState(store, 0); + InMemoryData.initDataState(store.data, 0); store.updateQuery({ query: Appointment, variables: { id: '1' } }, data => ({ ...data, appointment: { @@ -312,7 +315,7 @@ describe('Store with OptimisticMutationConfig', () => { info: 'urql meeting revisited', }, })); - clearStoreState(); + InMemoryData.clearDataState(); const { data: result } = query(store, { query: Appointment, @@ -329,10 +332,10 @@ describe('Store with OptimisticMutationConfig', () => { }); it('should be able to read a query', () => { - initStoreState(store, 0); + InMemoryData.initDataState(store.data, 0); const result = store.readQuery({ query: Todos }); - const deps = getCurrentDependencies(); + const deps = InMemoryData.getCurrentDependencies(); expect(deps).toEqual( new Set([ 'Query.todos', @@ -348,7 +351,7 @@ describe('Store with OptimisticMutationConfig', () => { __typename: 'Query', todos: todosData.todos, }); - clearStoreState(); + InMemoryData.clearDataState(); }); it('should be able to optimistically mutate', () => { @@ -394,7 +397,7 @@ describe('Store with OptimisticMutationConfig', () => { ], }); - store.clearOptimistic(1); + InMemoryData.clearOptimistic(store.data, 1); ({ data } = query(store, { query: Todos })); expect(data).toEqual({ __typename: 'Query', diff --git a/src/store.ts b/src/store/store.ts similarity index 57% rename from src/store.ts rename to src/store/store.ts index 464c557..b3931a3 100644 --- a/src/store.ts +++ b/src/store/store.ts @@ -3,8 +3,6 @@ import { createRequest } from 'urql/core'; import { Cache, - EntityField, - Link, FieldInfo, ResolverConfig, DataField, @@ -14,61 +12,14 @@ import { UpdatesConfig, OptimisticMutationConfig, KeyingConfig, -} from './types'; +} from '../types'; -import * as InMemoryData from './helpers/data'; -import { invariant, currentDebugStack } from './helpers/help'; -import { defer, keyOfField } from './helpers'; -import { read, readFragment } from './operations/query'; -import { writeFragment, startWrite } from './operations/write'; -import { invalidate } from './operations/invalidate'; -import { SchemaPredicates } from './ast/schemaPredicates'; - -let currentStore: null | Store = null; -let currentDependencies: null | Set = null; - -// Initialise a store run by resetting its internal state -export const initStoreState = (store: Store, optimisticKey: null | number) => { - InMemoryData.setCurrentOptimisticKey(optimisticKey); - currentStore = store; - currentDependencies = new Set(); - - if (process.env.NODE_ENV !== 'production') { - currentDebugStack.length = 0; - } -}; - -// Finalise a store run by clearing its internal state -export const clearStoreState = () => { - if (!(currentStore as Store).gcScheduled) { - defer((currentStore as Store).gc); - } - - InMemoryData.setCurrentOptimisticKey(null); - currentStore = null; - currentDependencies = null; - - if (process.env.NODE_ENV !== 'production') { - currentDebugStack.length = 0; - } -}; - -export const getCurrentDependencies = (): Set => { - invariant( - currentDependencies !== null, - 'Invalid Cache call: The cache may only be accessed or mutated during' + - 'operations like write or query, or as part of its resolvers, updaters, ' + - 'or optimistic configs.', - 2 - ); - - return currentDependencies; -}; - -// Add a dependency to the internal store state -export const addDependency = (dependency: string) => { - (currentDependencies as Set).add(dependency); -}; +import { read, readFragment } from '../operations/query'; +import { writeFragment, startWrite } from '../operations/write'; +import { invalidate } from '../operations/invalidate'; +import { SchemaPredicates } from '../ast'; +import { keyOfField } from './keys'; +import * as InMemoryData from './data'; type RootField = 'query' | 'mutation' | 'subscription'; @@ -91,8 +42,6 @@ export class Store implements Cache { optimisticMutations?: OptimisticMutationConfig, keys?: KeyingConfig ) { - this.data = InMemoryData.make(); - this.resolvers = resolvers || {}; this.optimisticMutations = optimisticMutations || {}; this.keys = keys || {}; @@ -139,6 +88,8 @@ export class Store implements Cache { Subscription: 'subscription', }; } + + this.data = InMemoryData.make(this.getRootKey('query')); } gcScheduled = false; @@ -173,57 +124,15 @@ export class Store implements Cache { return key ? `${typename}:${key}` : null; } - clearOptimistic(optimisticKey: number) { - InMemoryData.clearOptimistic(this.data, optimisticKey); - } - - getRecord(entityKey: string, fieldKey: string): EntityField { - return InMemoryData.readRecord(this.data, entityKey, fieldKey); - } - - writeRecord(field: EntityField, entityKey: string, fieldKey: string) { - InMemoryData.writeRecord(this.data, entityKey, fieldKey, field); - } - - getField( - entityKey: string, - fieldName: string, - args?: Variables - ): EntityField { - return InMemoryData.readRecord( - this.data, - entityKey, - keyOfField(fieldName, args) - ); - } - - writeField( - field: EntityField, - entityKey: string, - fieldName: string, - args?: Variables - ) { - return this.writeRecord(field, entityKey, keyOfField(fieldName, args)); - } - - getLink(entityKey: string, fieldKey: string): undefined | Link { - return InMemoryData.readLink(this.data, entityKey, fieldKey); - } - - writeLink(link: undefined | Link, entityKey: string, fieldKey: string) { - return InMemoryData.writeLink(this.data, entityKey, fieldKey, link); - } - resolveFieldByKey(entity: Data | string | null, fieldKey: string): DataField { const entityKey = entity !== null && typeof entity !== 'string' ? this.keyOfEntity(entity) : entity; if (entityKey === null) return null; - addDependency(entityKey); - const fieldValue = InMemoryData.readRecord(this.data, entityKey, fieldKey); + const fieldValue = InMemoryData.readRecord(entityKey, fieldKey); if (fieldValue !== undefined) return fieldValue; - const link = InMemoryData.readLink(this.data, entityKey, fieldKey); + const link = InMemoryData.readLink(entityKey, fieldKey); return link ? link : null; } @@ -239,21 +148,12 @@ export class Store implements Cache { invalidate(this, createRequest(query, variables)); } - hasField(entityKey: string, fieldKey: string): boolean { - return ( - InMemoryData.readRecord(this.data, entityKey, fieldKey) !== undefined || - InMemoryData.readLink(this.data, entityKey, fieldKey) !== undefined - ); - } - inspectFields(entity: Data | string | null): FieldInfo[] { const entityKey = entity !== null && typeof entity !== 'string' ? this.keyOfEntity(entity) : entity; - return entityKey !== null - ? InMemoryData.inspectFields(this.data, entityKey) - : []; + return entityKey !== null ? InMemoryData.inspectFields(entityKey) : []; } updateQuery( diff --git a/src/helpers/timing.ts b/src/store/timing.ts similarity index 100% rename from src/helpers/timing.ts rename to src/store/timing.ts diff --git a/src/test-utils/examples-1.test.ts b/src/test-utils/examples-1.test.ts index 47879e4..41632e6 100644 --- a/src/test-utils/examples-1.test.ts +++ b/src/test-utils/examples-1.test.ts @@ -1,5 +1,6 @@ import gql from 'graphql-tag'; import { query, write, writeOptimistic } from '../operations'; +import * as InMemoryData from '../store/data'; import { Store } from '../store'; import { Data } from '../types'; @@ -416,7 +417,7 @@ it('correctly resolves optimistic updates on Relay schemas', () => { write(store, { query: getRoot }, queryData); writeOptimistic(store, { query: updateItem, variables: { id: '2' } }, 1); - store.clearOptimistic(1); + InMemoryData.clearOptimistic(store.data, 1); const queryRes = query(store, { query: getRoot }); expect(queryRes.partial).toBe(false);