web: enable apollo-cache-persist (#23351)

This commit is contained in:
Valery Bugakov 2021-09-23 16:28:53 +08:00 committed by GitHub
parent b9d8621a1b
commit d35d3d4458
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 367 additions and 136 deletions

View File

@ -107,7 +107,9 @@ export function createGraphQLHelpers(sourcegraphURL: string, isExtension: boolea
* Memoized Apollo Client getter. It should be executed once to restore the cache from the local storage.
* After that, the same instance should be used by all consumers.
*/
const getBrowserGraphQLClient = once(() => getGraphQLClient({ headers: getHeaders(), baseUrl: sourcegraphURL }))
const getBrowserGraphQLClient = once(() =>
getGraphQLClient({ headers: getHeaders(), baseUrl: sourcegraphURL, isAuthenticated: false })
)
return { getBrowserGraphQLClient, requestGraphQL }
}

View File

@ -12,7 +12,7 @@ import { SettingsEdit } from './services/settings'
describe('MainThreadAPI', () => {
// TODO(tj): commands, notifications
const getGraphQLClient = () => getGraphQLClientBase({ headers: {} })
const getGraphQLClient = () => getGraphQLClientBase({ headers: {}, isAuthenticated: false })
describe('graphQL', () => {
test('PlatformContext#requestGraphQL is called with the correct arguments', async () => {

View File

@ -7,8 +7,7 @@ import {
ExtensionsWithPrioritizeExtensionIDsParamAndNoJSONFieldsResult,
ExtensionsWithPrioritizeExtensionIDsParamAndNoJSONFieldsVariables,
} from '../graphql-operations'
import { fromObservableQueryPromise } from '../graphql/fromObservableQuery'
import { getDocumentNode, gql } from '../graphql/graphql'
import { fromObservableQueryPromise, getDocumentNode, gql } from '../graphql/graphql'
import { PlatformContext } from '../platform/context'
import { createAggregateError } from '../util/errors'
@ -24,6 +23,7 @@ const ExtensionsQuery = gql`
extensionRegistry {
extensions(first: $first, extensionIDs: $extensionIDs) {
nodes {
id
extensionID
manifest {
jsonFields(fields: $extensionManifestFields)
@ -39,6 +39,7 @@ const ExtensionsWithPrioritizeExtensionIDsParameterAndNoJSONFieldsQuery = gql`
extensionRegistry {
extensions(first: $first, prioritizeExtensionIDs: $extensionIDs) {
nodes {
id
extensionID
manifest {
raw

View File

@ -1,10 +1,18 @@
import { InMemoryCache } from '@apollo/client'
import { TypedTypePolicies } from '../graphql-operations'
import { TypedTypePolicies } from '../../graphql-operations'
import { IExtensionRegistry } from '../schema'
// Defines how the Apollo cache interacts with our GraphQL schema.
// See https://www.apollographql.com/docs/react/caching/cache-configuration/#typepolicy-fields
const typePolicies: TypedTypePolicies = {
ExtensionRegistry: {
// Replace existing `ExtensionRegistry` with the incoming value.
// Required because of the missing `id` on the `ExtensionRegistry` field.
merge(existing: IExtensionRegistry, incoming: IExtensionRegistry): IExtensionRegistry {
return incoming
},
},
Query: {
fields: {
node: {

View File

@ -0,0 +1,77 @@
import { ApolloClient, createHttpLink, NormalizedCacheObject } from '@apollo/client'
import { LocalStorageWrapper, CachePersistor } from 'apollo3-cache-persist'
import { once } from 'lodash'
import { GRAPHQL_URI } from '../constants'
import { cache } from './cache'
import { persistenceMapper } from './persistenceMapper'
interface GetGraphqlClientOptions {
headers: RequestInit['headers']
isAuthenticated: boolean
baseUrl?: string
}
export type GraphQLClient = ApolloClient<NormalizedCacheObject>
/**
* 🚨 SECURITY: Use two unique keys for authenticated and anonymous users
* to avoid keeping private information in localStorage after logout.
*/
const getApolloPersistCacheKey = (isAuthenticated: boolean): string =>
`apollo-cache-persist-${isAuthenticated ? 'authenticated' : 'anonymous'}`
export const getGraphQLClient = once(
async (options: GetGraphqlClientOptions): Promise<GraphQLClient> => {
const { headers, baseUrl, isAuthenticated } = options
const uri = baseUrl ? new URL(GRAPHQL_URI, baseUrl).href : GRAPHQL_URI
const persistor = new CachePersistor({
cache,
persistenceMapper,
// Use max 4 MB for persistent cache. Leave 1 MB for other means out of 5 MB available.
// If exceeded, persistence will pause and app will start up cold on next launch.
maxSize: 1024 * 1024 * 4,
key: getApolloPersistCacheKey(isAuthenticated),
storage: new LocalStorageWrapper(window.localStorage),
})
// 🚨 SECURITY: Drop persisted cache item in case `isAuthenticated` value changed.
localStorage.removeItem(getApolloPersistCacheKey(!isAuthenticated))
await persistor.restore()
const apolloClient = new ApolloClient({
uri,
cache,
defaultOptions: {
/**
* The default `fetchPolicy` is `cache-first`, which returns a cached response
* and doesn't trigger cache update. This is undesirable default behavior because
* we want to keep our cache updated to avoid confusing the user with stale data.
* `cache-and-network` allows us to return a cached result right away and then update
* all consumers with the fresh data from the network request.
*/
watchQuery: {
fetchPolicy: 'cache-and-network',
},
/**
* `client.query()` returns promise, so it can only resolve one response.
* Meaning we cannot return the cached result first and then update it with
* the response from the network as it's done in `client.watchQuery()`.
* So we always need to make a network request to get data unless another
* `fetchPolicy` is specified in the `client.query()` call.
*/
query: {
fetchPolicy: 'network-only',
},
},
link: createHttpLink({
uri: ({ operationName }) => `${uri}?${operationName}`,
headers,
}),
})
return Promise.resolve(apolloClient)
}
)

View File

@ -0,0 +1,80 @@
import {
gql as apolloGql,
useQuery as useApolloQuery,
useMutation as useApolloMutation,
useLazyQuery as useApolloLazyQuery,
DocumentNode,
OperationVariables,
QueryHookOptions,
QueryResult,
MutationHookOptions,
MutationTuple,
QueryTuple,
} from '@apollo/client'
import { useMemo } from 'react'
type RequestDocument = string | DocumentNode
/**
* Returns a `DocumentNode` value to support integrations with GraphQL clients that require this.
*
* @param document The GraphQL operation payload
* @returns The created `DocumentNode`
*/
export const getDocumentNode = (document: RequestDocument): DocumentNode => {
if (typeof document === 'string') {
return apolloGql(document)
}
return document
}
const useDocumentNode = (document: RequestDocument): DocumentNode =>
useMemo(() => getDocumentNode(document), [document])
/**
* Send a query to GraphQL and respond to updates.
* Wrapper around Apollo `useQuery` that supports `DocumentNode` and `string` types.
*
* @param query GraphQL operation payload.
* @param options Operation variables and request configuration
* @returns GraphQL response
*/
export function useQuery<TData = any, TVariables = OperationVariables>(
query: RequestDocument,
options: QueryHookOptions<TData, TVariables>
): QueryResult<TData, TVariables> {
const documentNode = useDocumentNode(query)
return useApolloQuery(documentNode, options)
}
/**
* Unlike with `useQuery`, when you call `useLazyQuery`, it does not immediately execute its associated query.
* Wrapper around Apollo `useLazyQuery` that supports `DocumentNode` and `string` types.
*
* @param query GraphQL operation payload.
* @param options Operation variables and request configuration.
* @returns returns a query function in its result tuple that you call whenever you're ready to execute the query.
*/
export function useLazyQuery<TData = any, TVariables = OperationVariables>(
query: RequestDocument,
options: QueryHookOptions<TData, TVariables>
): QueryTuple<TData, TVariables> {
const documentNode = useDocumentNode(query)
return useApolloLazyQuery(documentNode, options)
}
/**
* Send a mutation to GraphQL and respond to updates.
* Wrapper around Apollo `useMutation` that supports `DocumentNode` and `string` types.
*
* @param mutation GraphQL operation payload.
* @param options Operation variables and request configuration
* @returns GraphQL response
*/
export function useMutation<TData = any, TVariables = OperationVariables>(
mutation: RequestDocument,
options?: MutationHookOptions<TData, TVariables>
): MutationTuple<TData, TVariables> {
const documentNode = useDocumentNode(mutation)
return useApolloMutation(documentNode, options)
}

View File

@ -0,0 +1,3 @@
export * from './fromObservableQuery'
export * from './client'
export * from './hooks'

View File

@ -0,0 +1,85 @@
import { persistenceMapper, ROOT_QUERY_KEY, CacheObject } from './persistenceMapper'
describe('persistenceMapper', () => {
const userKey = 'User:01'
const settingsKey = 'Settings:01'
const createStringifiedCache = (rootQuery: Record<string, unknown>, references?: Record<string, unknown>) =>
JSON.stringify({
[ROOT_QUERY_KEY]: {
__typename: 'query',
...rootQuery,
},
...references,
})
const parseCacheString = (cacheString: string) => JSON.parse(cacheString) as CacheObject
it('does not persist anything if the cache is empty', async () => {
const persistedString = await persistenceMapper(JSON.stringify({}))
expect(Object.keys(parseCacheString(persistedString))).toEqual([])
})
it('persists only hardcoded queries', async () => {
const persistedString = await persistenceMapper(
createStringifiedCache({
viewerSettings: { empty: null, data: true },
extensionRegistry: { data: true },
shouldNotBePersisted: {},
})
)
expect(Object.keys(parseCacheString(persistedString).ROOT_QUERY)).not.toContain('shouldNotBePersisted')
})
it('persists cache references', async () => {
const persistedString = await persistenceMapper(
createStringifiedCache(
{
viewerSettings: { data: { __ref: userKey } },
shouldNotBePersisted: {},
},
{
[userKey]: { settings: { __ref: settingsKey } },
[settingsKey]: { data: true },
}
)
)
expect(Object.keys(parseCacheString(persistedString))).toEqual([ROOT_QUERY_KEY, userKey, settingsKey])
})
it('persists array of cache references', async () => {
const persistedString = await persistenceMapper(
createStringifiedCache(
{
viewerSettings: { data: [{ __ref: userKey }, { __ref: settingsKey }] },
shouldNotBePersisted: {},
},
{
[userKey]: { settings: { __ref: settingsKey } },
[settingsKey]: { data: true },
}
)
)
expect(Object.keys(parseCacheString(persistedString))).toEqual([ROOT_QUERY_KEY, userKey, settingsKey])
})
it('persists deeply nested cache references', async () => {
const persistedString = await persistenceMapper(
createStringifiedCache(
{
viewerSettings: { data: { settings: { sourcegraph: { user: { __ref: userKey } } } } },
shouldNotBePersisted: {},
},
{
[userKey]: { data: true },
}
)
)
expect(Object.keys(parseCacheString(persistedString))).toEqual([ROOT_QUERY_KEY, userKey])
})
})

View File

@ -0,0 +1,80 @@
import { IQuery } from '../schema'
/**
* Hardcoded names of the queries which will be persisted to the local storage.
* After the implementation of the `persistLink` which will support `@persist` directive
* hardcoded query names will be deprecated.
*/
export const QUERIES_TO_PERSIST: (keyof IQuery)[] = ['viewerSettings', 'extensionRegistry', 'temporarySettings']
export const ROOT_QUERY_KEY = 'ROOT_QUERY'
export interface CacheReference {
__ref: string
}
export interface CacheObject {
ROOT_QUERY: Record<string, unknown>
[cacheKey: string]: unknown
}
// Ensures that we persist data required only for `QUERIES_TO_PERSIST`. Everything else is ignored.
export const persistenceMapper = (data: string): Promise<string> => {
const initialData = JSON.parse(data) as CacheObject
// If `ROOT_QUERY` cache is empty, return initial data right away.
if (!initialData[ROOT_QUERY_KEY] || Object.keys(initialData[ROOT_QUERY_KEY]).length === 0) {
return Promise.resolve(data)
}
const dataToPersist: Record<string, unknown> = {
[ROOT_QUERY_KEY]: {
__typename: initialData[ROOT_QUERY_KEY].__typename,
},
}
function findNestedCacheReferences(entry: unknown): void {
if (!entry) {
return
}
if (Array.isArray(entry)) {
for (const item of entry) {
findNestedCacheReferences(item)
}
} else if (isCacheReference(entry)) {
const referenceKey = entry.__ref
dataToPersist[referenceKey] = initialData[referenceKey]
findNestedCacheReferences(initialData[referenceKey])
} else if (entry && typeof entry === 'object') {
for (const item of Object.values(entry)) {
findNestedCacheReferences(item)
}
}
}
/**
* Add responses of the specified queries to the result object and
* go through nested fields of the persisted responses and add references used there to the result object.
*
* Example ROOT_QUERY: { viewerSettings: { user: { __ref: 'User:01' } }, 'User:01': { ... } }
* 'User:01' should be persisted, to have a complete cached response to the `viewerSettings` query.
*/
for (const queryName of QUERIES_TO_PERSIST) {
const entryToPersist = initialData[ROOT_QUERY_KEY][queryName]
if (entryToPersist) {
Object.assign(dataToPersist[ROOT_QUERY_KEY], {
[queryName]: entryToPersist,
})
findNestedCacheReferences(entryToPersist)
}
}
return Promise.resolve(JSON.stringify(dataToPersist))
}
function isCacheReference(entry: any): entry is CacheReference {
return Boolean(entry.__ref)
}

View File

@ -0,0 +1 @@
export const GRAPHQL_URI = '/.api/graphql'

View File

@ -1,22 +1,4 @@
import {
gql as apolloGql,
useQuery as useApolloQuery,
useMutation as useApolloMutation,
useLazyQuery as useApolloLazyQuery,
DocumentNode,
ApolloClient,
createHttpLink,
NormalizedCacheObject,
OperationVariables,
QueryHookOptions,
QueryResult,
MutationHookOptions,
MutationTuple,
QueryTuple,
} from '@apollo/client'
import { GraphQLError } from 'graphql'
import { once } from 'lodash'
import { useMemo } from 'react'
import { Observable } from 'rxjs'
import { fromFetch } from 'rxjs/fetch'
import { Omit } from 'utility-types'
@ -24,7 +6,7 @@ import { Omit } from 'utility-types'
import { checkOk } from '../backend/fetch'
import { createAggregateError } from '../util/errors'
import { cache } from './cache'
import { GRAPHQL_URI } from './constants'
/**
* Use this template string tag for all GraphQL queries.
@ -67,8 +49,6 @@ export interface GraphQLRequestOptions extends Omit<RequestInit, 'method' | 'bod
baseUrl?: string
}
const GRAPHQL_URI = '/.api/graphql'
/**
* This function should not be called directly as it does not
* add the necessary headers to authorize the GraphQL API call.
@ -94,107 +74,4 @@ export function requestGraphQLCommon<T, V = object>({
})
}
interface GetGraphqlClientOptions {
headers: RequestInit['headers']
baseUrl?: string
}
export type GraphQLClient = ApolloClient<NormalizedCacheObject>
export const getGraphQLClient = once(
async (options: GetGraphqlClientOptions): Promise<GraphQLClient> => {
const { headers, baseUrl } = options
const uri = baseUrl ? new URL(GRAPHQL_URI, baseUrl).href : GRAPHQL_URI
const apolloClient = new ApolloClient({
uri,
cache,
defaultOptions: {
/**
* The default `fetchPolicy` is `cache-first`, which returns a cached response
* and doesn't trigger cache update. This is undesirable default behavior because
* we want to keep our cache updated to avoid confusing the user with stale data.
* `cache-and-network` allows us to return a cached result right away and then update
* all consumers with the fresh data from the network request.
*/
watchQuery: {
fetchPolicy: 'cache-and-network',
},
/**
* `client.query()` returns promise, so it can only resolve one response.
* Meaning we cannot return the cached result first and then update it with
* the response from the network as it's done in `client.watchQuery()`.
* So we always need to make a network request to get data unless another
* `fetchPolicy` is specified in the `client.query()` call.
*/
query: {
fetchPolicy: 'network-only',
},
},
link: createHttpLink({
uri: ({ operationName }) => `${uri}?${operationName}`,
headers,
}),
})
return Promise.resolve(apolloClient)
}
)
type RequestDocument = string | DocumentNode
/**
* Returns a `DocumentNode` value to support integrations with GraphQL clients that require this.
*
* @param document The GraphQL operation payload
* @returns The created `DocumentNode`
*/
export const getDocumentNode = (document: RequestDocument): DocumentNode => {
if (typeof document === 'string') {
return apolloGql(document)
}
return document
}
const useDocumentNode = (document: RequestDocument): DocumentNode =>
useMemo(() => getDocumentNode(document), [document])
/**
* Send a query to GraphQL and respond to updates.
* Wrapper around Apollo `useQuery` that supports `DocumentNode` and `string` types.
*
* @param query GraphQL operation payload.
* @param options Operation variables and request configuration
* @returns GraphQL response
*/
export function useQuery<TData = any, TVariables = OperationVariables>(
query: RequestDocument,
options: QueryHookOptions<TData, TVariables>
): QueryResult<TData, TVariables> {
const documentNode = useDocumentNode(query)
return useApolloQuery(documentNode, options)
}
export function useLazyQuery<TData = any, TVariables = OperationVariables>(
query: RequestDocument,
options: QueryHookOptions<TData, TVariables>
): QueryTuple<TData, TVariables> {
const documentNode = useDocumentNode(query)
return useApolloLazyQuery(documentNode, options)
}
/**
* Send a mutation to GraphQL and respond to updates.
* Wrapper around Apollo `useMutation` that supports `DocumentNode` and `string` types.
*
* @param mutation GraphQL operation payload.
* @param options Operation variables and request configuration
* @returns GraphQL response
*/
export function useMutation<TData = any, TVariables = OperationVariables>(
mutation: RequestDocument,
options?: MutationHookOptions<TData, TVariables>
): MutationTuple<TData, TVariables> {
const documentNode = useDocumentNode(mutation)
return useApolloMutation(documentNode, options)
}
export * from './apollo'

View File

@ -1,7 +1,7 @@
import { MockedProvider, MockedProviderProps } from '@apollo/client/testing'
import React, { useMemo } from 'react'
import { generateCache } from '../../graphql/cache'
import { generateCache } from '../../graphql/apollo/cache'
export const MockedTestProvider: React.FunctionComponent<MockedProviderProps> = ({ children, ...props }) => {
/**

View File

@ -266,6 +266,7 @@ export const createSharedIntegrationTestContext = async <
DISPOSE_ACTION_TIMEOUT,
new Error('Recording coverage timed out')
)
await driver.page.evaluate(() => localStorage.clear())
await pTimeout(driver.page.close(), DISPOSE_ACTION_TIMEOUT, new Error('Closing Puppeteer page timed out'))
await pTimeout(polly.stop(), DISPOSE_ACTION_TIMEOUT, new Error('Stopping Polly timed out'))
},

View File

@ -78,6 +78,7 @@ export function setupExtensionMocking({
// Mutate mock data objects
extensionSettings[id] = true
extensionsResult.extensionRegistry.extensions.nodes.push({
id,
extensionID: id,
manifest: {
jsonFields: extensionManifest,

View File

@ -3,11 +3,11 @@ import { MockedProvider, MockedProviderProps, MockedResponse, MockLink } from '@
import { getOperationName } from '@apollo/client/utilities'
import React from 'react'
import { cache } from '@sourcegraph/shared/src/graphql/cache'
import { cache } from '@sourcegraph/shared/src/graphql/apollo/cache'
/**
* Intercept each mocked Apollo request and ensure that any request variables match the specified mock.
* This effectively means we are mocking agains the operationName of the query being fired.
* This effectively means we are mocking against the operationName of the query being fired.
*/
const forceMockVariablesLink = (mocks: readonly MockedResponse[]): ApolloLink =>
new ApolloLink((operation, forward) => {

View File

@ -68,6 +68,7 @@ export const mutateGraphQL = (request: string, variables?: {}): Observable<Graph
*/
export const getWebGraphQLClient = memoize(() =>
getGraphQLClient({
isAuthenticated: window.context.isAuthenticatedUser,
headers: {
...window?.context?.xhrHeaders,
'X-Sourcegraph-Should-Trace': new URLSearchParams(window.location.search).get('trace') || 'false',

View File

@ -223,6 +223,7 @@ describe('Blob viewer', () => {
extensions: {
nodes: [
{
id: 'test',
extensionID: 'test/test',
manifest: {
jsonFields: extensionManifest,
@ -292,6 +293,7 @@ describe('Blob viewer', () => {
})
interface MockExtension {
id: string
extensionID: string
extensionManifest: ExtensionManifest
/**
@ -308,6 +310,7 @@ describe('Blob viewer', () => {
it('adds and clears line decoration attachments properly', async () => {
const mockExtensions: MockExtension[] = [
{
id: 'test',
extensionID: 'test/fixed-line',
extensionManifest: {
url: new URL(
@ -369,6 +372,7 @@ describe('Blob viewer', () => {
},
},
{
id: 'selected-line',
extensionID: 'test/selected-line',
extensionManifest: {
url: new URL(
@ -616,6 +620,7 @@ describe('Blob viewer', () => {
*/
const wordFinder: MockExtension = {
id: 'word-finder',
extensionID: 'test/word-finder',
extensionManifest: {
url: new URL(
@ -904,6 +909,7 @@ describe('Blob viewer', () => {
extensions: {
nodes: [
{
id: 'test',
extensionID: 'test/references',
manifest: {
jsonFields: extensionManifest,

View File

@ -93,12 +93,14 @@ const registryExtensionNodes: RegistryExtensionFieldsForList[] = [
const extensionNodes: ExtensionsResult['extensionRegistry']['extensions']['nodes'] = [
{
id: 'typescript',
extensionID: 'sourcegraph/typescript',
manifest: {
jsonFields: typescriptRawManifest,
},
},
{
id: 'count',
extensionID: 'sqs/word-count',
manifest: {
jsonFields: wordCountRawManifest,

View File

@ -752,6 +752,7 @@ describe('Repository', () => {
extensions: {
nodes: [
{
id: 'test',
extensionID: 'test/test',
manifest: {
jsonFields: extensionManifest,

View File

@ -3,8 +3,7 @@ import { map, publishReplay, refCount, shareReplay } from 'rxjs/operators'
import { Tooltip } from '@sourcegraph/branded/src/components/tooltip/Tooltip'
import { createExtensionHost } from '@sourcegraph/shared/src/api/extension/worker'
import { fromObservableQueryPromise } from '@sourcegraph/shared/src/graphql/fromObservableQuery'
import { getDocumentNode, gql } from '@sourcegraph/shared/src/graphql/graphql'
import { fromObservableQueryPromise, getDocumentNode, gql } from '@sourcegraph/shared/src/graphql/graphql'
import * as GQL from '@sourcegraph/shared/src/graphql/schema'
import { PlatformContext } from '@sourcegraph/shared/src/platform/context'
import { mutateSettings, updateSettings } from '@sourcegraph/shared/src/settings/edit'

View File

@ -3,7 +3,7 @@ import { isEqual } from 'lodash'
import { Observable, of, Subscription, from, ReplaySubject, Subscriber } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators'
import { fromObservableQuery } from '@sourcegraph/shared/src/graphql/fromObservableQuery'
import { fromObservableQuery } from '@sourcegraph/shared/src/graphql/graphql'
import { GetTemporarySettingsResult } from '../../graphql-operations'

View File

@ -336,6 +336,7 @@
"@visx/pattern": "^1.7.0",
"@visx/scale": "^1.7.0",
"@visx/xychart": "^1.7.3",
"apollo3-cache-persist": "^0.12.1",
"bloomfilter": "^0.0.18",
"bootstrap": "^4.5.2",
"classnames": "^2.2.6",

View File

@ -6637,6 +6637,11 @@ anymatch@^2.0.0:
micromatch "^3.1.4"
normalize-path "^2.1.1"
apollo3-cache-persist@^0.12.1:
version "0.12.1"
resolved "https://registry.npmjs.org/apollo3-cache-persist/-/apollo3-cache-persist-0.12.1.tgz#6641e398cb3285dbc8c6cbdd9ecea3e25bff79c7"
integrity sha512-bYh/OzhzR70URRRf0OkzkpTLB7+fwx1Ftiw8/MWW8NhEgF+K9u3FO786DSUcE6X2NqSObYKF0+CpM9K8vrJNxw==
app-root-dir@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118"