From 0ececd1ad6f2d075c68b82192e5d594f475e3a8e Mon Sep 17 00:00:00 2001 From: bloodyowl Date: Sat, 6 Apr 2024 11:26:35 +0200 Subject: [PATCH] Experimental: connections update --- src/cache/cache.ts | 39 ++++++++++ src/client.ts | 89 ++++++++++++++++++++- src/index.ts | 1 + src/react/usePagination.ts | 41 +++++----- src/types.ts | 18 +++++ test/__snapshots__/cache.test.ts.snap | 106 ++++++++++++++++++++++++++ test/cache.test.ts | 63 +++++++++++++++ 7 files changed, 338 insertions(+), 19 deletions(-) diff --git a/src/cache/cache.ts b/src/cache/cache.ts index 43a74bd..20783d3 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -176,6 +176,12 @@ export class ClientCache { typeof value.__typename === "string" && value.__typename.endsWith("Connection") ) { + value.__connectionCacheKey = cacheKey.description; + value.__connectionCachePath = [ + [...writePath, fieldNameWithArguments].map((item) => + typeof item === "symbol" ? { symbol: item.description } : item, + ), + ]; value.__connectionArguments = variables; } @@ -207,4 +213,37 @@ export class ClientCache { writePath.push(pathCopy.pop() as PropertyKey); } } + + update( + cacheKey: symbol, + path: (symbol | string)[], + updater: (value: A) => Partial, + ) { + this.get(cacheKey).map((cachedAncestor) => { + const value = path.reduce>( + // @ts-expect-error fromNullable makes it safe + (acc, key) => acc.flatMap((acc) => Option.fromNullable(acc[key])), + Option.fromNullable(cachedAncestor.value), + ); + + value.map((item) => { + const deepUpdate = path.reduce( + (acc, key) => { + return { + [key]: acc, + }; + }, + updater(item as A), + ); + + this.set( + cacheKey, + mergeCacheEntries(cachedAncestor, { + requestedKeys: new Set(), + value: deepUpdate, + }), + ); + }); + }); + } } diff --git a/src/client.ts b/src/client.ts index 9115413..4d1fc8b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,7 +16,7 @@ import { inlineFragments, } from "./graphql/ast"; import { print } from "./graphql/print"; -import { TypedDocumentNode } from "./types"; +import { Connection, Edge, TypedDocumentNode } from "./types"; export type RequestConfig = { url: string; @@ -217,4 +217,91 @@ export class Client { ) { return this.request(document, variables, requestOptions); } + + updateConnection>( + connection: T, + config: + | { prepend: Edge[] } + | { append: Edge[] } + | { remove: string[] }, + ) { + match(connection as unknown) + .with( + { + __connectionCacheKey: P.string, + __connectionCachePath: P.array( + P.array(P.union({ symbol: P.string }, P.string)), + ), + }, + ({ __connectionCacheKey, __connectionCachePath }) => { + const cacheKey = Symbol.for(__connectionCacheKey); + const cachePath = __connectionCachePath.map((path) => + path.map((item) => + typeof item === "string" ? item : Symbol.for(item.symbol), + ), + ); + const edgesSymbol = Symbol.for("edges"); + const nodeSymbol = Symbol.for("node"); + match(config) + .with({ prepend: P.select(P.array()) }, (edges) => { + const firstPath = cachePath[0]; + if (firstPath != null) { + this.cache.update(cacheKey, firstPath, (value) => + // @ts-expect-error safe + value[edgesSymbol] != null + ? { + // @ts-expect-error safe + ...value, + // @ts-expect-error safe + [edgesSymbol]: [...edges, ...value[edgesSymbol]], + } + : value, + ); + } + }) + .with({ append: P.select(P.array()) }, (edges) => { + const lastPath = cachePath[cachePath.length - 1]; + if (lastPath != null) { + this.cache.update(cacheKey, lastPath, (value) => + // @ts-expect-error safe + value[edgesSymbol] != null + ? { + // @ts-expect-error safe + ...value, + // @ts-expect-error safe + [edgesSymbol]: [...value[edgesSymbol], ...edges], + } + : value, + ); + } + }) + .with({ remove: P.select(P.array()) }, (nodeIds) => { + cachePath.forEach((path) => { + this.cache.update(cacheKey, path, (value) => { + // @ts-expect-error safe + return value[edgesSymbol] != null + ? { + // @ts-expect-error safe + ...value, + // @ts-expect-error safe + [edgesSymbol]: value[edgesSymbol].filter((edge) => { + const node = edge[nodeSymbol] as symbol; + return !nodeIds.some((nodeId) => { + return node.description?.includes(`<${nodeId}>`); + }); + }), + } + : value; + }); + }); + }) + .exhaustive(); + }, + ) + .otherwise(() => {}); + + this.subscribers.forEach((func) => { + func(); + }); + } } diff --git a/src/index.ts b/src/index.ts index f71f522..6a225b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,4 @@ export * from "./react/useDeferredQuery"; export * from "./react/useMutation"; export * from "./react/usePagination"; export * from "./react/useQuery"; +export { Connection, Edge } from "./types"; diff --git a/src/react/usePagination.ts b/src/react/usePagination.ts index 34ec7eb..6542b39 100644 --- a/src/react/usePagination.ts +++ b/src/react/usePagination.ts @@ -1,25 +1,8 @@ import { useRef } from "react"; import { match } from "ts-pattern"; +import { Connection } from "../types"; import { isRecord } from "../utils"; -export type Edge = { - cursor?: string | null; - node?: T | null | undefined; -}; - -export type Connection = - | { - edges?: (Edge | null | undefined)[] | null | undefined; - pageInfo: { - hasPreviousPage?: boolean | null | undefined; - hasNextPage?: boolean | null | undefined; - endCursor?: string | null | undefined; - startCursor?: string | null | undefined; - }; - } - | null - | undefined; - type mode = "before" | "after"; const mergeConnection = >( @@ -53,6 +36,28 @@ const mergeConnection = >( return { ...next, + __connectionArguments: match(mode) + .with("before", () => [ + ...("__connectionCachePath" in next && + Array.isArray(next.__connectionCachePath) + ? next.__connectionCachePath + : []), + ...("__connectionCachePath" in previous && + Array.isArray(previous.__connectionCachePath) + ? previous.__connectionCachePath + : []), + ]) + .with("after", () => [ + ...("__connectionCachePath" in previous && + Array.isArray(previous.__connectionCachePath) + ? previous.__connectionCachePath + : []), + ...("__connectionCachePath" in next && + Array.isArray(next.__connectionCachePath) + ? next.__connectionCachePath + : []), + ]) + .exhaustive(), edges: match(mode) .with("before", () => [...(next.edges ?? []), ...(previous.edges ?? [])]) .with("after", () => [...(previous.edges ?? []), ...(next.edges ?? [])]) diff --git a/src/types.ts b/src/types.ts index 1cbc607..d7ea585 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,3 +20,21 @@ export interface TypedDocumentNode< }, > extends DocumentNode, DocumentTypeDecoration {} + +export type Edge = { + cursor?: string | null; + node?: T | null | undefined; +}; + +export type Connection = + | { + edges?: (Edge | null | undefined)[] | null | undefined; + pageInfo: { + hasPreviousPage?: boolean | null | undefined; + hasNextPage?: boolean | null | undefined; + endCursor?: string | null | undefined; + startCursor?: string | null | undefined; + }; + } + | null + | undefined; diff --git a/test/__snapshots__/cache.test.ts.snap b/test/__snapshots__/cache.test.ts.snap index c840bdd..a00a123 100644 --- a/test/__snapshots__/cache.test.ts.snap +++ b/test/__snapshots__/cache.test.ts.snap @@ -16,6 +16,14 @@ Map { "__connectionArguments": { "first": "2", }, + "__connectionCacheKey": "Query", + "__connectionCachePath": [ + [ + { + "symbol": "accountMemberships({"first":"2"})", + }, + ], + ], Symbol(__typename): "AccountMembershipConnection", Symbol(edges): [ { @@ -140,6 +148,14 @@ Map { "__connectionArguments": { "first": "2", }, + "__connectionCacheKey": "Query", + "__connectionCachePath": [ + [ + { + "symbol": "accountMemberships({"first":"2"})", + }, + ], + ], Symbol(__typename): "AccountMembershipConnection", Symbol(edges): [ { @@ -289,6 +305,14 @@ Map { "__connectionArguments": { "first": "2", }, + "__connectionCacheKey": "Query", + "__connectionCachePath": [ + [ + { + "symbol": "accountMemberships({"first":"2"})", + }, + ], + ], Symbol(__typename): "AccountMembershipConnection", Symbol(edges): [ { @@ -590,3 +614,85 @@ Map { }, } `; + +exports[`Write & read in cache 7`] = ` +t { + "tag": "Some", + "value": t { + "tag": "Ok", + "value": { + "__typename": "Query", + "accountMembership": { + "__typename": "AccountMembership", + "id": "account-membership-1", + "user": { + "__typename": "User", + "firstName": "Matthias", + "id": "user-1", + "identificationLevels": null, + "lastName": "Le Brun", + }, + }, + "accountMemberships": { + "__connectionArguments": { + "first": "2", + }, + "__connectionCacheKey": "Query", + "__connectionCachePath": [ + [ + { + "symbol": "accountMemberships({"first":"2"})", + }, + ], + ], + "__typename": "AccountMembershipConnection", + "edges": [ + { + "__typename": "AccountMembershipEdge", + "node": { + "__typename": "AccountMembership", + "account": { + "__typename": "Account", + "name": "Second", + }, + "id": "account-membership-2", + "membershipUser": { + "__typename": "User", + "id": "user-2", + "lastName": "Last", + }, + }, + }, + { + "__typename": "AccountMembershipEdge", + "node": { + "__typename": "AccountMembership", + "account": { + "__typename": "Account", + "name": "First", + }, + "id": "account-membership-3", + "membershipUser": { + "__typename": "User", + "id": "user-3", + "lastName": "Le Brun", + }, + }, + }, + ], + }, + "supportingDocumentCollection": { + "__typename": "SupportingDocumentCollection", + "id": "supporting-document-collection-1", + "supportingDocuments": [ + { + "__typename": "SupportingDocument", + "createdAt": "2024-03-14T12:06:10.857Z", + "id": "supporting-document-1", + }, + ], + }, + }, + }, +} +`; diff --git a/test/cache.test.ts b/test/cache.test.ts index 56c23be..2267402 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -1,5 +1,6 @@ import { Option, Result } from "@swan-io/boxed"; import { expect, test } from "vitest"; +import { Client, Connection } from "../src"; import { ClientCache } from "../src/cache/cache"; import { optimizeQuery, readOperationFromCache } from "../src/cache/read"; import { writeOperationToCache } from "../src/cache/write"; @@ -199,4 +200,66 @@ test("Write & read in cache", () => { language: "en", }), ).toMatchObject(Option.Some(Result.Ok(onboardingInfoResponse))); + + const client = new Client({ url: "/" }); + + writeOperationToCache( + client.cache, + preparedAppQuery, + getAppQueryResponse({ + user2LastName: "Last", + user1IdentificationLevels: null, + }), + { + id: "1", + }, + ); + + const read = readOperationFromCache(cache, preparedAppQuery, { + id: "1", + }); + + if (read.isSome()) { + const cacheResult = read.get(); + if (cacheResult.isOk()) { + const value = cacheResult.get() as ReturnType; + client.updateConnection( + value.accountMemberships as unknown as Connection, + { + remove: ["account-membership-1"], + }, + ); + client.updateConnection( + value.accountMemberships as unknown as Connection, + { + append: [ + { + __typename: "AccountMembershipEdge", + node: { + __typename: "AccountMembership", + id: "account-membership-3", + account: { + __typename: "Account", + name: "First", + }, + membershipUser: { + __typename: "User", + id: "user-3", + lastName: "Le Brun", + }, + }, + }, + ], + }, + ); + } + + expect( + readOperationFromCache(client.cache, preparedAppQuery, { + id: "1", + }), + ).toMatchSnapshot(); + } else { + expect(true).toBe(false); + } });