graphql-live-query icon indicating copy to clipboard operation
graphql-live-query copied to clipboard

Invalidation should trigger a call to getParameter to get a fresh context

Open strblr opened this issue 2 years ago • 9 comments
trafficstars

Hey,

For some context, I was facing the following bug: https://github.com/n1ru4l/graphql-live-query/issues/980#issuecomment-1340320142

If I'm not mistaken, getParameter is not re-called when re-executing an invalidated query. This is I think the cause of the bug: dataloaders are instantiated when building the contextValue, so in getParameter. Thus, subsequent invalidation would make use of old dataloaders, with stale cached data in it. If any resolver depends on dataloaders, invalidation re-execution would use outdated data.

This seems like a major bug / limitation, dataloaders are very much needed in large apps. I'd suggest including the call to getParameter inside the routine that is launched on invalidation.

strblr avatar Dec 07 '22 04:12 strblr

Hey @strblr, thank you for all the feedback so far ❤️

I wouldn't categorize it as a major bug as this is already how Subscriptions work all the time. I definitely agree with you, that this is a major limitation.

We can allow passing a second (optional) function parameter to the makeExecute method here: https://github.com/n1ru4l/graphql-live-query/blob/9d35033163c31bbead6223bcce7e3450a0876990/packages/in-memory-live-query-store/src/InMemoryLiveQueryStore.ts#L233

 (execute: typeof defaultExecute, contextFactory: Context | (initialContext: Context) => PromiseOrValue<Context>) => 

What do you think? Would you be open to creating the PR for this?

n1ru4l avatar Dec 07 '22 07:12 n1ru4l

Thank you for your quick response @n1ru4l, I'm glad if my feedback was useful !

If I understand your idea correctly, it would be to pass the initial contextValue to contextFactory here:

https://github.com/n1ru4l/graphql-live-query/blob/9d35033163c31bbead6223bcce7e3450a0876990/packages/in-memory-live-query-store/src/InMemoryLiveQueryStore.ts#L354-L359

Like this:

[originalContextSymbol]: await contextFactory(contextValue),

?

strblr avatar Dec 07 '22 15:12 strblr

@strblr Exactly!

n1ru4l avatar Dec 07 '22 15:12 n1ru4l

@n1ru4l This would make run async, thus changing a lot of typings, and maybe it will have other side-effects that I'm not aware off. Is this really a no-brainer?

strblr avatar Dec 08 '22 00:12 strblr

I tried to find a workaround without changing the library. I don't understand why something like this doesn't work:

import { execute as defaultExecute, ExecutionArgs } from "graphql";
import { flowRight } from "lodash-es";
// ...

const live = new InMemoryLiveQueryStore();

const makeLazyContextExecute = (contextFactory: () => unknown) => (
  execute: typeof defaultExecute
) => async (args: ExecutionArgs) =>
  execute({ ...args, contextValue: await contextFactory() });

registerSocketIOGraphQLServer({
  socketServer,
  getParameter: async ({ graphQLPayload: { extensions } }) => ({
    execute: flowRight(
      applyLiveQueryJSONPatchGenerator,
      flowRight(
        makeLazyContextExecute(() => getContext(extensions?.authorization)),
        live.makeExecute
      )(defaultExecute)
    ),
    graphQLExecutionParameter: { schema }
  })
});

// getContext creates a new context with new dataloaders

It's a bit like the idea you suggested, only using external composition to create a special execute function.

In my understanding, this should totally discard the initial contextValue provided by graphQLExecutionParameter and replace it with a fresh call to getContext every time execute is called. But it still doesn't create a new context on invalidation. Do you have an idea why?

Thanks a lot!

strblr avatar Dec 08 '22 04:12 strblr

Fixed it. There were two issues:

  • Composition order: makeLazyContextExecute needs to be wrapped inside live.makeExecute for it to be cached for invalidation, not the other way around.
  • Distinguishing between live and non-live queries. With live queries, the initial context is a LiveQueryContextValue containing a symbol.

Although hacky, this works:

const makeLazyContextExecute = (
  contextFactory: (previous: unknown) => unknown
) => (execute: typeof defaultExecute) => async (args: ExecutionArgs) =>
  execute({ ...args, contextValue: await contextFactory(args.contextValue) });

registerSocketIOGraphQLServer({
  socketServer,
  getParameter: async ({ graphQLPayload: { extensions } }) => ({
    execute: flowRight(
      applyLiveQueryJSONPatchGenerator,
      flowRight(
        live.makeExecute,
        makeLazyContextExecute(async previous => {
          const ctx = await getContext(extensions?.authorization);
          const sym =
            isObject(previous) && Object.getOwnPropertySymbols(previous)[0];
          return !sym ? ctx : { ...previous, [sym]: ctx };
        })
      )(defaultExecute)
    ),
    graphQLExecutionParameter: { schema }
  })
});

(Exporting originalContextSymbol would make this solution cleaner)

I'll still try to PR the solution you suggested @n1ru4l

strblr avatar Dec 09 '22 02:12 strblr

@strblr Yeah, I think the simpler API would be very convenient for library users! Looking forward to your PR!

n1ru4l avatar Dec 16 '22 17:12 n1ru4l

Hi! Did anything come out of this? :)

maxbol avatar Oct 05 '23 07:10 maxbol

Had a quick go at an envelop-implementation that keeps the API relatively simple for the user. Works for both live and non-live queries. Lemme know what you think!

Edit: It also implements patch delivery format based on context.

useLiveQuery.ts:

export interface UseLiveQueryOptions<Context extends Record<string, any>> {
  applyLiveQueryPatchGenerator?: (
    context: Context,
  ) => ReturnType<typeof createApplyLiveQueryPatchGenerator> | null;

  contextFactory: (previous: Context) => unknown;

  liveQueryStore: InMemoryLiveQueryStore;
}

const makeLazyContextExecute =
  <Context extends Record<string, any>>(
    contextFactory: (previous: Context) => unknown,
  ) =>
  (execute: typeof defaultExecute) =>
    makeStrongExecute(async (args) => {
      const liveQuerySymbol =
        isObject(args.contextValue) &&
        Object.getOwnPropertySymbols(args.contextValue)[0];

      if (liveQuerySymbol) {
        const ctx = await contextFactory(args.contextValue[liveQuerySymbol]);
        return execute({
          ...args,
          contextValue: { ...args.contextValue, [liveQuerySymbol]: ctx },
        });
      } else {
        const ctx = await contextFactory(args.contextValue);
        return execute({
          ...args,
          contextValue: ctx,
        });
      }
    });

const makeWithPatchDeliveryExecute =
  <Context extends Record<string, any>>(opts: UseLiveQueryOptions<Context>) =>
  (execute: typeof defaultExecute) =>
    makeStrongExecute(async (args: TypedExecutionArgs<Context>) => {
      const applyLiveQueryPatchGenerator = opts.applyLiveQueryPatchGenerator
        ? opts.applyLiveQueryPatchGenerator(args.contextValue)
        : undefined;

      if (applyLiveQueryPatchGenerator) {
        return pipe(args, execute, applyLiveQueryPatchGenerator);
      }

      return pipe(args, execute);
    });

export const useLiveQuery = <Context extends Record<string, any>>(
  opts: UseLiveQueryOptions<Context>,
): Plugin<Record<string, any>> => {
  return {
    onContextBuilding: ({ extendContext }) => {
      extendContext({
        liveQueryStore: opts.liveQueryStore,
      });
    },
    onExecute: ({ executeFn, setExecuteFn }) => {
      const execute = pipe(
        executeFn,
        makeLazyContextExecute(opts.contextFactory),
        opts.liveQueryStore.makeExecute,
        makeWithPatchDeliveryExecute(opts),
      );

      setExecuteFn(execute);
    },
    onValidate: ({ addValidationRule }) => {
      addValidationRule(NoLiveMixedWithDeferStreamRule);
    },
  };
};

makeStrongExecute.ts:

export function makeStrongExecute<ReturnType>(
  weakExecute: (args: ExecutionArgs) => ReturnType,
) {
  function execute(args: ExecutionArgs): ReturnType;
  function execute(
    schema: GraphQLSchema,
    document: DocumentNode,
    rootValue?: any,
    contextValue?: any,
    variableValues?: Maybe<{ [key: string]: any }>,
    operationName?: Maybe<string>,
    fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>,
    typeResolver?: Maybe<GraphQLTypeResolver<any, any>>,
  ): ReturnType;
  function execute(
    argsOrSchema: ExecutionArgs | GraphQLSchema,
    ...otherArgs: any[]
  ): ReturnType {
    if (argsOrSchema instanceof GraphQLSchema) {
      return weakExecute({
        contextValue: otherArgs[2],
        document: otherArgs[0],
        fieldResolver: otherArgs[5],
        operationName: otherArgs[4],
        rootValue: otherArgs[1],
        schema: argsOrSchema,
        typeResolver: otherArgs[6],
        variableValues: otherArgs[3],
      });
    }
    return weakExecute(argsOrSchema);
  }
  return execute;
}

usage.ts:

function rescopeContext(context: Context) {
  return { ...context, scope: v4() };
}

function getLiveQueryPatchGenerator(context: Context) {
  const { patchDeliveryFormat } = context;

  if (patchDeliveryFormat === PatchDeliveryFormat.JSONDIFFPATCH) {
    return applyLiveQueryJSONDiffPatchGenerator;
  }

  if (patchDeliveryFormat === PatchDeliveryFormat.JSONPATCH) {
    return applyLiveQueryJSONPatchGenerator;
  }

  return null;
}

useLiveQuery({
  applyLiveQueryPatchGenerator: getLiveQueryPatchGenerator,
  contextFactory: rescopeContext,
  liveQueryStore,
}),

maxbol avatar Oct 05 '23 14:10 maxbol