wasp-lang / wasp

The fastest way to develop full-stack web apps with React & Node.js.
https://wasp-lang.dev
MIT License
12.73k stars 1.13k forks source link

Fix runtime guessing for server-side operations API #2050

Open sodic opened 1 month ago

sodic commented 1 month ago

Context

Consider the definition of an authenticated operation getFoo:

export const getFoo: GetFoo<Input, Output> = (args, context) => {
    // ...
}

As of https://github.com/wasp-lang/wasp/pull/2044, Wasp generates a server-side API that users can use to call operations from the server's runtime.

Users can define their operations to either expect a payload or not expect a payload (configured through the operation's type), and Wasp must consider this when generating the server-side API: If the operation's definition expects a payload, so should its generated API (and vice versa).

The generated server-side API also depends on whether the application is authenticated (i.e., auth: true):

This leaves us with four possible call signatures for an operation's server-side API:

auth: true auth: false
With payload getFoo(payload, context) getFoo(payload)
Without payload getFoo(context) getFoo()

Behavior

Unauthenticated operations

For unauthenticated operations, everything works as expected.

TypeScript enforces one of the call signatures from the second column. So, if the user's using TypeScript, everything's OK.

If the user isn't using TypeScript, the provided payload (or lack thereof) is always forwarded to the operation's definition. This is consistent with JS's behavior:

Call signature Called with Behavior
getFoo() getFoo(payload) ✅ All good, payload is ignored .
getFoo(payload) getFoo() ✅ All good, payload is undefined.

Unauthenticated operations

This is where things get messy.

Since neither the Generator nor the runtime knows the function's true type signature (i.e., its actual number of arguments), the runtime must "guess" which of the two possible signatures is correct (i.e., whether the operation expects a payload or not) and act accordingly:

When the user is using TypeScript, TypeScript enforces that the user sends the correct number of arguments to this function, while the function's runtime decides on the proper implementation based on the number of arguments it receives. Therefore, if the user's using TypeScript, everything's OK.

If the user isn't using TypeScript, there are possible discrepancies between the function's true type and the type runtime decides on:

Call signature Called with Behavior
getFoo(context) getFoo() ✅ All good, an appropriate error is thrown (illegal number of arguments)
getFoo(context) getFoo(arg1, arg2) ❌ Not good, arg1 is treated as the payload, arg2 is treated as the context. The action's definition is called with (arg1, { ...arg2, entities }), while the expected call would be (undefined, { ...arg1, entities }).
getFoo(payload, context) getFoo() ✅ All good, the appropriate error is thrown (illegal number of arguments).
getFoo(payload, context) getFoo(arg1) ❌ Not good, arg1 is treated as the context. The action's definition is called with (undefined, { ...arg1, entities }), while the expected call would be (arg1, { ...undefined, entities }).

Solutions

Ideas for fixing this include: