Closed infomiho closed 1 month ago
They can also import action directly, from its file. In which case they have to construct the context
completely on their own, while allegedly, if they import it via wasp/server/operations
, they don't have to construct it completely on their own, but need to pass just user
to the context and rest will be added.
We should confirm this and decide if we want to document "raw" import approach also or not.
@Martinsos I'm not sure I follow how to call queries without the useQuery
hook.
From the docs I had initially understood that wasp would magically add the context, even when calling the function without the hook. Per your last comment, that is not the case, though, and I'm still confused on how to actually do it.
It seems that to directly call the query I must pass the query cache key, plus the user (as context). The advanced usage section for actions says we can get the queryCacheKey
from the "operation" itself, but that doesn't work:
import { getTasks } from 'wasp/client/operations';
// ...
const { data: tasks } = await getTasks(getTasks.queryCacheKey, { user });
I would suggest adding to the docs an example of how to call queries with entities without the useQuery
hook. I would also say the concepts valid for both queries and actions could be in a separate section that appears before queries and actions, perhaps directly in Overview.
edit: opened a question in discord too.
@leoschet As mentioned on Discord, this issue deals with calling Operations from the server, so it's not really applicable to your situation.
However, the advanced usage docs you linked do talk about calling Operations from client, and you've successfully caught two mistakes. I've created an issue for fixing the docs and improving the API: https://github.com/wasp-lang/wasp/issues/1923
Thanks!
Here's a proposal for the API for calling Operations directly on the server.
I'll explore the API on an authenticated Query. Actions are analogous, and unauthenticated operations offer a subset of what's shown here (I show an example at the end of each section).
Defining the Operations remains the same, but I'll give a definition to emphasize the relationship between the types.
Assuming an authenticated Query getFoo
defined as:
import { type GetFoo } from 'wasp/server/operations'
export const getFoo: GetFoo<Input, Output> = async (args, context) => {
// ...
}
We want to enable our users to do this:
import { getFoo } from 'wasp/server/operations'
const payload: Input = { ... }
const user: User = prisma.user.findUnique(...) // This this their 'userEntity'
const result: Output = await getFoo(payload, user)
// If 'getFoo' wasn't authenticated
const result: Output = await getFoo(payload)
Here's a list of things I considered but gave up on. You can try to change my mind if you disagree :)
AuthUser
object instead of a User
object:User object
: it's their entity and is a real entity.User
object requires no "proprietary" helper functions.User
object into an AuthUser
object is simple.userId: string
instead of a User
object:string
. The User
object forces them to perform a database query.User
object or a userId: string
string
is error-prone (see above).Assuming we go with the proposed API, here's what we have to do.
The true type of getFoo
(as defined above) is:
function getFoo(
args: Input,
context: {
entities: Entities, // All entities the Operation needs, injected by Wasp
user?: AuthUser | undefined // AuthUser != User (which they send)
}
): Promise<Output>
Therefore, to make this work, Wasp would have to generate the following wrapper function and make it available through wasp/server/operations
:
// The type 'User' is 'userEntity' defined in the Wasp file.
function getFoo(args: Input, user: User) Promise<Output> { ... }
// If getFoo wasn't authenticated
function getFoo(args: Input) Promise<Output> { ... }
The wrapper has to:
AuthUser
value from a User
value - simple enough :)Inject the rest of the necessary stuff (i.e., entities) - We already do this in the router and can reuse that logic.
@Martinsos asked:
They can also import action directly, from its file. In which case they have to construct the context completely on their own [ ... ] We should confirm this and decide if we want to document "raw" import approach also or not.
My opinion is that we shouldn't document this.
Let's focus on one official way to do it (i.e., through wasp/server/operations
) and make that as easy as possible.
It keeps our API simple and clean.
Of course, our users will still be able to call their operations directly. There's no way to stop them - it's just calling a JavaScript function without anything extra going on (which is also why I don't think we should document it). They might have a hard time constructing the AuthUser
object, but I think that's good (it encourages users to use the simpler API).
0️⃣ I like the proposed user API, I'm just calling operations with the payload and/or with the user
👍
1️⃣ I'd maybe recommend defining the second param as minimal as possible e.g. { id: <type> }
, since we only need the ID to construct the full AuthUser
.
2️⃣ Keep in mind that this would be a bit expensive since it would fetch:
User
(if we decide to send the ID)Auth
objectAuthIdentity
objectsIf they are calling the action in an another action - maybe they already have the constructed AuthUser
which they can forward to the called action for free in terms of DB queries. I'm not sure if we should be optimizing this - but I wanted to point out the hidden cost which we might avoid.
Either we accept { id: string } | AuthUser
or we have some sort of composable helper method like getFood(payload, createAuthUser({ id })
🤷
Great points!
I'll address each of them and give my conclusion.
1️⃣ I'd maybe recommend defining the second param as minimal as possible e.g. { id:
}, since we only need the ID to construct the full AuthUser.
Hmm, interesting idea. I was about to accept it, but then realized it suffers from the same problem the raw ID suffers from - it's easy to address a non-existing user.
Also, the type the users see ({ id: string }
) is not as pedagogical as User
and I'm afraid it might steer them away from what we want them to do - query the database.
Btw, { id: string } | AuthUser
resolves to just { id: string }
because one is a supertype of the other (just like number | 4 == number
). So users would just see { id: string }
.
Right after typing that, I figured that we can fix it with overloads (playground). They provide great DX, actually.
2️⃣ Keep in mind that this would be a bit expensive since it would fetch...
That is true. There's only a single way it could be made more efficient and it's, as you said, by asking them to always pass an AuthUser
object.
They'd still have to fetch it themselves, but yes, if they already have it, then it's an improvement.
getFood(payload, createAuthUser({ id })
As you might have guessed, I'm not a fan of this kind of API. :smile: I'd rather sometimes do two an extra database Query then expose our plumbing.
I like to option of doing User | AuthUser
(via overloads) and acting differently depending on which one we get.
If you really like the "pass me only the ID option", I'm also OK with { id: string } | AuthUser
(via overloads).
The only question is whether we should add a raw string
to the mix, but I think we can skip that until someone asks for it.
Should we maybe ignore all possible optimizations and just implement the User
or { id: string }
solution?
This API is not super popular, and we might be optimizing prematurely.
Nice analysis!
await getFoo(payload, { user })
? And then merging that context
, which is really the second argument, with whatever context we wanted to inject. It seems more logical to me, because you are calling it the same way it was defined. Seems a bit confusing that you defined it to take { user } but not it takes just user
.AuthUser
as an arg type, potentially User | AuthUser
, for all the arguments that were already written. The biggest argument for me is that most often they will be calling it from another Operation, and they already have AuthUser there. If not, and they are calling it from somewhere else like db seed function or setup function, they should be able to easily obtain at least User
. This contributes to the consistency of the APIs between how I define the function and how I am calling it.One more (big) detail I haven't thought about.
Let's put aside the issue of how exactly we pass the user (i.e., User
vs AuthUser
vs { user: AuthUser }
and others).
We agreed it makes sense to expose an operation like this:
const result = await getFoo(payload, user)
But what about operations that don't expect any arguments, for example:
import { type GetBar } from 'wasp/server/operations'
export const getBar: GetBar<void, Output> = async (_args, context) => {
// ...
}
We could make the number of positional arguments depend on whether the operation expects arguments:
const foo = await getFoo(payload, user) // Expects an argument
const bar = await getBar(user) // Doesn't expect an argument
But this looks a little janky. Moving the user to the front doesn't solve the problem - the number of arguments now depends on whether the operation is authenticated:
const foo = await getFoo(user, payload) // auth: true
const bar = await getBar(payload) // auth: false
Any ideas on what we should do? The only thing I can think of is ditching positional arguments in favor of objects (they do have their benefits anyway, see https://github.com/wasp-lang/wasp/pull/554#discussion_r847449529):
// auth: true, takes arguments
const foo = await getFoo({
payload,
user,
})
// auth: true, doesn't take arguments
const bar = await getBar({
user,
})
// auth: false, doesn't take arguments
const baz = await getBaz({}) // kind of dumb
const baz = await getBaz() // kind of inconsistent
@infomiho and @Martinsos please weigh in on those, preferably with something creative that's a gamechanger :)
@sodic to me it seems pretty clear, but maybe I am missing something: I would stick with how the function is defined.
That means the call would be:
const result = await getFoo(payload, user)
And if there are no arguments, then it is just
const result = await getFoo({}, user)
which makes sense, because arguments are provided as a single object (payload) where arguments are fields, and in this case there are none so that objects is empty, and all is good.
That said, I would even go with
const result = await getFoo({}, { user })
without feeling bad about it. I don't know if we will want to be putting something more in context in the future or not, and this leaves it flexible while also keeping it consistent with how it is defined.
Look at it this way:
await getFoo({arg1, arg2, ...}, { user, entities, ... })
We could in theory go with await getFoo({args: {arg1, arg2}, context: {user}})
, but we are quite confident that split into args and context is enough, so this is probably too much.
So again, we end up with getFoo({...}, {...})
as a way to go. It looks the same as the signature, it gives flexibility to us to allow putting more stuff in context if needed.
@Martinsos thanks for the input. Let's leave all the user stuff aside for now and look at an unauthenticated query.
which makes sense, because arguments are provided as a single object (payload) where arguments are fields, and in this case there are none so that objects is empty, and all is good.
You'd be 100% percent right if the bold part were true, but unfortunately, it's not :sweat_smile:.
We can (and often do) have:
export const getFoo: GetFoo<number, Foo> = async (n, context) => {
const x = args + 12 // n is just a number
// ...
}
In other words, args
can be any JS type (more precisely, SuperJson type). It's not limited to an object with fields.
In practice, the {}
idea would look like this:
getFoo({ id }) // getFoo: GetFoo<{ id: number }, Foo>
getFoo(13) // getFoo: GetFoo<number, Foo>
getFoo("bar") // getFoo: GetFoo<string, Foo>
getFoo([1, 2]) // getFoo: GetFoo<number[], Foo>
getFoo({}) // getFoo: GetFoo<void, Foo>
getFoo({}) // getFoo: GetFoo<{ id?: number }, Foo>
This doesn't seem right.
More importantly, since void
is not the same as {}
, it opens the API up to many potential type errors.
I'll give two examples.
Consider this operation:
export const getFoo = async (args: { filter?: string } = { filter: "active" }) => {
// ...
}
The author wants to:
filter
field, where the missing filter
field (empty object) means "no filter".{ filter: "active" }
when the argument is left out.How would you call it and trigger the default filter (active
only)?
getFoo({}) // means args is {}, i.e., no filter
Yes, this is not peak API design, but that's up to our users :)
Let's assume we've defined getFoo
as GetFoo<void, Foo>
. Now consider the following call site:
getFoo({})
Changing getFoo
's definition to GetFoo<{ id?: string }, Foo>
won't report any type errors for this call. Users would probably like to know they should update their call site and send an empty object explicitly (which means "I don't want to specify an id
").
Ah whoops, ok I forgot it can be anything, you are right!
Ok, but in that case, we just stick with whatever they defined, right?
If they don't use the first argument, and define it as void
, what would you expect them to pass at call site? Since my TS game is not yet 100%, I asked ChatGPT, and it said we should be using void
for a parameter that is not used (is it making sense?). Suggested instead defining it as getFoo(_: undefined, ...)
, and then passing undefined
to it. Which sounds ok to me?
If not, what do you think is the best alternative? Allowing dropping first argument? But then it becomes quite confusing, if tehre is just one argument, which one did you drop, the first one, the second one, ... , right?
Had a call with @Martinsos. We are going with:
{ user }
instead of user
) because:
user
can be either User | AuthUser
getFoo(payload, { user })
or getFoo(payload)
, or getFoo({ user }
) depending on auth
and void
) because:
Update by @sodic
When attempting to fix this, I realized our server-side Operations API is virtually unusable:
AuthUser
(which also isn't public or documented). Even if it was documented, it still wouldn't be a great API :)This happened because we created this API as an afterthought and plugged users into an arbitrary point of our internal RPC architecture. Therefore, the API has never really been user friendly. To make things worse, since it's basically a hook into our internal implementation, it can easily change without us noticing (e.g., it happened with
AuthUser
).This problem slipped under the radar for so long because calling operations from the server isn't that crucial of a feature, but we should still fix it.
Idea: create a separate wrapper for server side queries that's as clean as possible and does most of the ugly work under the hood.
Fixing this will also fix part of #1958.
Original issue
Currently we say
and
which is incorrect. The users shouldn't import the actions and queries on the server from
wasp/client/operation
but rather fromwasp/server/operations
.Also, we need to explain that they need to provide the
context
as the first argument and then the arguments as the second argument.Discord thread about this: https://ptb.discord.com/channels/686873244791210014/1218567315185209464/1219591538942804019