SeedCompany / cord-api-v3

Bible translation project management API
MIT License
18 stars 4 forks source link

[EdgeDB] `currentUserId` global refactor #3140

Closed CarsonF closed 5 months ago

CarsonF commented 5 months ago

This follows up on #2941

Interceptor fail

Previously, the currentUserId was put in the EdgeDB globals context via a NestJS Interceptor.

But interceptors only cover the main query/mutation method (like a controller). GQL field resolvers are walked/invoked after this lifecycle as needed. So those field resolvers would not have the currentUser set ❌

Aside: I might have been thrown off here, because the transaction context is applied with an interceptor. But this is fine because DB changes only happen within the main mutation method. Field resolvers after that are just reading back the new DB state.

Session updates missed

Additionally, queries/mutations that deal with authentication did not work either. For session, that interceptor was skipped, because the session was being created within that resolver (after interceptors). For login/register, the session was created as anonymous, and the later updated to be for that user after the login succeeded. So this change wasn't picked up and applied to the DB global.

Interceptor -> Middleware

This is fixed by using a middleware for the options ALS context. Middleware's do compass the field resolvers, and the entire request.

Aside: this could be multiple GQL operations (queries/mutations), but this is fine because the current user is given via an HTTP header, so all operations within the request are the same.

BehaviorSubjects - to set new option later

However, because middleware's cover the entire request, all we have at that point is the request. We'd have to process it to get the given session token, and then do a DB lookup to get the associated user ID. We need a way to tell our EdgeDB code to use "these" options, but be able to update those options later, when we actually have the value.

In c866579cd1facb79754477b7a67df350d6415ad4, I change the options ALS to hold rxjs BehaviorSubjects. These have a current value, but are also Subjects which can have new values pushed to them. They are also Observables, so the value changes can be subscribed to as well. The cleverest thing here 😅 is that the ALS holds a BehaviorSubject of the current function that reduces the previous options and a BehaviorSubject of the parent options. This allows any node in the context tree to change its options, and of its descendants will refresh appropriately.

This sounds complicated, but it practice it makes much more sense.

const currentUser$ = new BehaviorSubject(
  options => options // no change for now
);
EdgeDB.usingOptions(currentUser$, () => {
  // globals now: { }

  EdgeDB.usingOptions(options => options.withGlobals({ color: 'blue' }), () => {
    // globals now { color: 'blue' }

    // now we have the current user
    currentUser$.next(options => options.withGlobals({ currentUserId: '...' })
    // globals in the outer closure: { currentUserId: '...' }
    // globals in this closure: { currentUserId: '...', color: 'blue' }
  });

  // Again, globals here: { currentUserId: '...' }
});

Here's the middleware that sets creates the ALS context scope for the request, with a BehaviorSubject (initialized as a noop).

BehaviorSubjects - to receive new Session value

Now we have a way to update that global later when we get it, but how do we do that? First step: f02b332 - convert the session held in the GQL context to a BehaviorSubject. This allows others to subscribe to session changes as they happen. This fixes the problem with login/register where the session is updated later, but our "current user logic" is not informed of this. Then we use an Interceptor again to acquire this GQL context and its session BehaviorSubject. Then we can connect the two BehaviorSubjects together.

Wrap up

Essentially all of this boils down to:

new session value -> set db global: { currentUserId: session.userId }

It was a lot of work to get here, but I'm pretty happy with the result. Setting this global is hard, because:

The end result leaves this one class, EdgeDBCurrentUserProvider, responsible for this connection. And it is loosely coupled to how the current session is determined.

https://seed-company-squad.monday.com/boards/5989610236/pulses/6374337425