Skip to content

Commit 4f7a19f

Browse files
authored
Merge pull request #95 from aave/feat/cache-short-circuits
feat: implement cache short circuits
2 parents 5eba52e + 1b8abde commit 4f7a19f

File tree

7 files changed

+287
-30
lines changed

7 files changed

+287
-30
lines changed

packages/client/src/actions/hubs.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
} from '@aave/graphql-next';
1212
import type { ResultAsync } from '@aave/types-next';
1313
import type { AaveClient } from '../AaveClient';
14-
import { type CurrencyQueryOptions, DEFAULT_QUERY_OPTIONS } from '../options';
14+
import {
15+
type CurrencyQueryOptions,
16+
DEFAULT_QUERY_OPTIONS,
17+
type RequestPolicyOptions,
18+
} from '../options';
1519

1620
/**
1721
* Fetches a specific hub by address and chain ID.
@@ -31,9 +35,13 @@ import { type CurrencyQueryOptions, DEFAULT_QUERY_OPTIONS } from '../options';
3135
export function hub(
3236
client: AaveClient,
3337
request: HubRequest,
34-
options: Required<CurrencyQueryOptions> = DEFAULT_QUERY_OPTIONS,
38+
options: CurrencyQueryOptions & RequestPolicyOptions = DEFAULT_QUERY_OPTIONS,
3539
): ResultAsync<Hub | null, UnexpectedError> {
36-
return client.query(HubQuery, { request, ...options });
40+
return client.query(
41+
HubQuery,
42+
{ request, currency: options.currency ?? DEFAULT_QUERY_OPTIONS.currency },
43+
options.requestPolicy ?? DEFAULT_QUERY_OPTIONS.requestPolicy,
44+
);
3745
}
3846

3947
/**

packages/client/src/actions/user.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type { AaveClient } from '../AaveClient';
2929
import {
3030
type CurrencyQueryOptions,
3131
DEFAULT_QUERY_OPTIONS,
32+
type RequestPolicyOptions,
3233
type TimeWindowQueryOptions,
3334
} from '../options';
3435

@@ -226,9 +227,13 @@ export function userBalances(
226227
export function userHistory(
227228
client: AaveClient,
228229
request: UserHistoryRequest,
229-
options: Required<CurrencyQueryOptions> = DEFAULT_QUERY_OPTIONS,
230+
options: CurrencyQueryOptions & RequestPolicyOptions = DEFAULT_QUERY_OPTIONS,
230231
): ResultAsync<PaginatedUserHistoryResult, UnexpectedError> {
231-
return client.query(UserHistoryQuery, { request, ...options });
232+
return client.query(
233+
UserHistoryQuery,
234+
{ request, currency: options.currency ?? DEFAULT_QUERY_OPTIONS.currency },
235+
options.requestPolicy ?? DEFAULT_QUERY_OPTIONS.requestPolicy,
236+
);
232237
}
233238

234239
/**

packages/client/src/cache.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { ReservesRequestFilter } from '@aave/graphql-next';
2+
import {
3+
assertOk,
4+
bigDecimal,
5+
chainId,
6+
evmAddress,
7+
never,
8+
nonNullable,
9+
} from '@aave/types-next';
10+
import { beforeAll, describe, expect, it } from 'vitest';
11+
import { hub, hubs, reserves, supply, userHistory } from './actions';
12+
import {
13+
client,
14+
createNewWallet,
15+
ETHEREUM_FORK_ID,
16+
ETHEREUM_WETH_ADDRESS,
17+
} from './test-utils';
18+
import { sendWith } from './viem';
19+
20+
const user = await createNewWallet();
21+
22+
describe('Given the Aave SDK normalized graph cache', () => {
23+
describe(`When fetching a single 'Hub'`, () => {
24+
it('Then it should leverage cached data whenever possible', async () => {
25+
const primed = await hubs(client, {
26+
query: {
27+
chainIds: [chainId(1)],
28+
},
29+
});
30+
assertOk(primed);
31+
32+
const result = await hub(
33+
client,
34+
{
35+
hub: nonNullable(primed.value[0]).address,
36+
chainId: nonNullable(primed.value[0]).chain.chainId,
37+
},
38+
{
39+
requestPolicy: 'cache-only',
40+
},
41+
);
42+
43+
assertOk(result);
44+
expect(result.value).toEqual(primed.value[0]);
45+
});
46+
});
47+
48+
describe('When fetching user history by tx hash', () => {
49+
beforeAll(async () => {
50+
const setup = await reserves(client, {
51+
query: {
52+
tokens: [
53+
{
54+
chainId: ETHEREUM_FORK_ID,
55+
address: ETHEREUM_WETH_ADDRESS,
56+
},
57+
],
58+
},
59+
filter: ReservesRequestFilter.Supply,
60+
})
61+
.map(
62+
(list) =>
63+
list.find((reserve) => reserve.canSupply) ??
64+
never('No reserve found to supply to for the token'),
65+
)
66+
.andThen((reserve) =>
67+
// supply activity 1
68+
supply(client, {
69+
sender: evmAddress(user.account.address),
70+
reserve: {
71+
chainId: reserve.chain.chainId,
72+
reserveId: reserve.id,
73+
spoke: reserve.spoke.address,
74+
},
75+
amount: {
76+
native: bigDecimal('0.1'),
77+
},
78+
enableCollateral: false, // workaround temporary contracts limitations
79+
})
80+
.andThen(sendWith(user))
81+
.andThen(client.waitForTransaction)
82+
.andThen(() =>
83+
// supply activity 2
84+
supply(client, {
85+
sender: evmAddress(user.account.address),
86+
reserve: {
87+
chainId: reserve.chain.chainId,
88+
reserveId: reserve.id,
89+
spoke: reserve.spoke.address,
90+
},
91+
amount: {
92+
native: bigDecimal('0.1'),
93+
},
94+
enableCollateral: false, // workaround temporary contracts limitations
95+
})
96+
.andThen(sendWith(user))
97+
.andThen(client.waitForTransaction),
98+
),
99+
);
100+
101+
assertOk(setup);
102+
});
103+
104+
it('Then it should leverage cached data whenever possible', async () => {
105+
const primed = await userHistory(client, {
106+
user: evmAddress(user.account.address),
107+
filter: {
108+
chainIds: [ETHEREUM_FORK_ID],
109+
},
110+
});
111+
assertOk(primed);
112+
113+
const result = await userHistory(
114+
client,
115+
{
116+
user: evmAddress(user.account.address),
117+
filter: {
118+
txHash: {
119+
txHash:
120+
primed.value.items[0]?.txHash ?? never('Expected a tx hash'),
121+
chainId: ETHEREUM_FORK_ID,
122+
},
123+
},
124+
},
125+
{
126+
requestPolicy: 'cache-only',
127+
},
128+
);
129+
130+
assertOk(result);
131+
expect(result.value.items[0]).toEqual(primed.value.items[0]);
132+
});
133+
});
134+
});

packages/client/src/cache.ts

Lines changed: 112 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
1-
import type {
2-
BorrowActivity,
3-
Chain,
4-
Erc20Token,
5-
Hub,
6-
HubAsset,
7-
LiquidatedActivity,
8-
NativeToken,
9-
RepayActivity,
10-
Reserve,
11-
ReserveInfo,
12-
Spoke,
13-
SupplyActivity,
14-
SwapActivity,
15-
SwapByIntent,
16-
SwapByIntentWithApprovalRequired,
17-
SwapByTransaction,
18-
UserPosition,
19-
WithdrawActivity,
1+
import {
2+
type BorrowActivity,
3+
type Chain,
4+
type Erc20Token,
5+
type Hub,
6+
type HubAsset,
7+
type HubQuery,
8+
isTxHashInputVariant,
9+
type LiquidatedActivity,
10+
type NativeToken,
11+
type RepayActivity,
12+
type Reserve,
13+
type ReserveInfo,
14+
type Spoke,
15+
type SupplyActivity,
16+
type SwapActivity,
17+
type SwapByIntent,
18+
type SwapByIntentWithApprovalRequired,
19+
type SwapByTransaction,
20+
type UserHistoryQuery,
21+
type UserPosition,
22+
type VariablesOf,
23+
type WithdrawActivity,
2024
} from '@aave/graphql-next';
2125
import introspectedSchema from '@aave/graphql-next/schema';
26+
import type { DateTime, TxHash } from '@aave/types-next';
2227
import {
2328
cacheExchange,
2429
type Resolver,
@@ -46,17 +51,100 @@ export const exchange = cacheExchange({
4651
// value: transformToBigInt,
4752
// nonce: transformToBigInt,
4853
// },
54+
55+
Query: {
56+
hub: (_, { request }: VariablesOf<typeof HubQuery>) => {
57+
return {
58+
__typename: 'Hub',
59+
address: request.hub,
60+
chain: {
61+
__typename: 'Chain',
62+
chainId: request.chainId,
63+
},
64+
assets: [],
65+
};
66+
},
67+
68+
userHistory: (
69+
_parent,
70+
args: VariablesOf<typeof UserHistoryQuery>,
71+
cache,
72+
) => {
73+
// Bail out if not a txHash filter lookup
74+
if (!isTxHashInputVariant(args.request.filter)) {
75+
return cache.resolve('Query', 'userHistory', args);
76+
}
77+
78+
const { txHash, chainId } = args.request.filter.txHash;
79+
80+
// Collect all cached pages for Query.userHistory
81+
const matches = cache
82+
.inspectFields('Query')
83+
.filter((f) => f.fieldName === 'userHistory')
84+
.reduce((set, f) => {
85+
const pageRef = cache.resolve('Query', f.fieldKey) as string | null;
86+
if (!pageRef) return set;
87+
88+
const itemRefs = cache.resolve(pageRef, 'items') as string[] | null;
89+
if (!itemRefs) return set;
90+
91+
for (const ref of itemRefs) {
92+
set.add(ref);
93+
}
94+
return set;
95+
}, new Set<string>())
96+
.values()
97+
.toArray()
98+
.filter((ref) => {
99+
const itemTxHash = cache.resolve(ref, 'txHash') as TxHash;
100+
if (itemTxHash !== txHash) return false;
101+
102+
// Verify chainId if spoke.chain.chainId exists
103+
const spokeRef = cache.resolve(ref, 'spoke') as string | null;
104+
if (spokeRef) {
105+
const chainRef = cache.resolve(spokeRef, 'chain') as
106+
| string
107+
| null;
108+
const itemChainId = chainRef
109+
? (cache.resolve(chainRef, 'chainId') as number | undefined)
110+
: undefined;
111+
if (typeof itemChainId === 'number') {
112+
return itemChainId === chainId;
113+
}
114+
}
115+
return true;
116+
})
117+
.sort((a, b) => {
118+
const ta = cache.resolve(a, 'id') as DateTime;
119+
const tb = cache.resolve(b, 'id') as DateTime;
120+
return tb.localeCompare(ta); // desc
121+
});
122+
123+
if (matches.length === 0) return undefined;
124+
125+
return {
126+
__typename: 'PaginatedUserHistoryResult',
127+
items: matches,
128+
pageInfo: {
129+
__typename: 'PaginatedResultInfo',
130+
prev: null,
131+
next: null,
132+
},
133+
};
134+
},
135+
},
49136
},
50137
keys: {
51138
// Entitied with composite key
52-
Hub: (data: Hub) => `Hub:${data.address}/chain:${data.chain.chainId}`,
139+
Hub: (data: Hub) => `address=${data.address},chain=${data.chain.chainId}`,
53140
HubAsset: (data: HubAsset) =>
54-
`HubAsset:${data.assetId}/hub:${data.hub.address}/chain:${data.hub.chain.chainId}`,
141+
`assetId=${data.assetId},hub=${data.hub.address},chain=${data.hub.chain.chainId}`,
55142
Reserve: (data: Reserve) =>
56-
`Reserve:${data.id}/spoke:${data.spoke.address}/chain:${data.chain.chainId}`,
143+
`reserveId=${data.id},spoke=${data.spoke.address},chain=${data.chain.chainId}`,
57144
ReserveInfo: (data: ReserveInfo) =>
58-
`ReserveInfo:${data.id}/spoke:${data.spoke.address}/chain:${data.chain.chainId}`,
59-
Spoke: (data: Spoke) => `Spoke:${data.address}/chain:${data.chain.chainId}`,
145+
`reserveId=${data.id},spoke=${data.spoke.address},chain=${data.chain.chainId}`,
146+
Spoke: (data: Spoke) =>
147+
`address=${data.address},chain=${data.chain.chainId}`,
60148

61149
// Entities with id field as key
62150
BorrowActivity: (data: BorrowActivity) => data.id,

packages/client/src/options.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Currency, TimeWindow } from '@aave/graphql-next';
2+
import type { RequestPolicy } from '@urql/core';
23

34
export type CurrencyQueryOptions = {
45
/**
@@ -18,7 +19,18 @@ export type TimeWindowQueryOptions = {
1819
timeWindow?: TimeWindow;
1920
};
2021

22+
export type RequestPolicyOptions = {
23+
/**
24+
* The request policy to use.
25+
*
26+
* @internal This is used for testing purposes and could be changed without notice.
27+
* @defaultValue `cache-and-network`
28+
*/
29+
requestPolicy?: RequestPolicy;
30+
};
31+
2132
export const DEFAULT_QUERY_OPTIONS = {
2233
currency: Currency.Usd,
2334
timeWindow: TimeWindow.LastDay,
35+
requestPolicy: 'cache-and-network',
2436
} as const;

packages/graphql/src/graphql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import type {
4040
} from './enums';
4141
import type { introspection } from './graphql-env';
4242

43-
export type { FragmentOf } from 'gql.tada';
43+
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
4444

4545
export const graphql = initGraphQLTada<{
4646
disableMasking: true;

packages/graphql/src/inputs.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export type ReserveErc20AmountInputWithPermit = ReturnType<
4848
typeof graphql.scalar<'ReserveErc20AmountInputWithPermit'>
4949
>;
5050
export type TxHashInput = ReturnType<typeof graphql.scalar<'TxHashInput'>>;
51+
52+
/**
53+
* @internal
54+
*/
55+
export function isTxHashInputVariant<T>(
56+
input: T,
57+
): input is T & { txHash: TxHashInput } {
58+
return isObject(input) && 'txHash' in input && input.txHash != null;
59+
}
60+
5161
export type UserSpokeInput = ReturnType<
5262
typeof graphql.scalar<'UserSpokeInput'>
5363
>;

0 commit comments

Comments
 (0)