Skip to content

Unify ClientQuerier with UniversalQuerier through a defunctionalized result wrapper #96

Description

@rogerpadilla

ClientQuerier and UniversalQuerier declare the same 13 methods twice. The only differences are the return wrapper (RequestSuccessResponse<T> / RequestCountedSuccessResponse<T>) and the options type (RequestOptions instead of QueryOptions). browser/type/clientQuerier.ts says why:

These differences prevent clean extends UniversalQuerier - TypeScript does not support higher-kinded type wrappers, so the interfaces are kept in sync by convention.

That is beatable by defunctionalizing the wrapper: put the shapes in a lookup keyed by transport, and select with an indexed access.

Why now

The find methods carry five type parameters each since QueryFindResult landed, so every read signature is ~7 lines rather than 1, duplicated across both interfaces. Sync-by-convention is a worse deal at that size, and drift is silent in the direction that matters: a client signature that falls behind stops narrowing rows without failing anything.

The mechanism (prototyped, works)

interface ReadResult<T> {
  server: { one: Promise<T | undefined>; many: Promise<T[]>; counted: Promise<[T[], number]> };
  client: {
    one: Promise<RequestSuccessResponse<T | undefined>>;
    many: Promise<RequestSuccessResponse<T[]>>;
    counted: Promise<RequestCountedSuccessResponse<T[]>>;
  };
}
type Transport = keyof ReadResult<unknown>;
type Read<W extends Transport, Shape extends keyof ReadResult<unknown>[Transport], T> = ReadResult<T>[W][Shape];

interface Reads<W extends Transport, O> {
  findMany<E extends object, const S extends FieldKey<E> = never /* … */>(
    entity: Type<E>,
    q: QueryProjected<E, S, V, X, P>,
    opts?: O,
  ): Read<W, 'many', QueryFindResult<E, S, V, X, P>>;
}

interface UniversalQuerier extends Reads<'server', QueryOptions> {}
interface ClientQuerier extends Reads<'client', RequestOptions> {}

Checked on a prototype: the projection still narrows through it on both transports, a typo'd key is still reported, and return types resolve to a plain Promise<Story[]> in errors and hovers - the Read<…> indirection does not leak into diagnostics.

Scope

  • Reads: findOneById, findOne, findMany, findManyAndCount shared; findManyStream stays server-only
  • Writes: insertOne/insertMany/update*/upsert*/save*/delete*/restore*, where the duplication is the other ~90 lines
  • Per-method options that differ (deleteOneById takes QueryOptions & RequestOptions on the client) carried as a parameter rather than special-cased
  • ClientQuerierCoversAllCrudOperations keeps passing (keyof includes inherited members, so it should)
  • Delete the sync-by-convention note once it stops being true

Blind alleys, so nobody retries them

  • Sharing the signatures with the implementing classes (AbstractQuerier, AbstractQuerierPool, HttpQuerier) is not possible. A class merged with an interface that restates one of its own members is TS2300: Duplicate identifier; a class-interface merge can only add members. Arrow-function properties would work and are not worth per-instance closures on a read path.
  • Letting Querier inherit the entity-argument overload and declare only the $entity one is not possible either. A derived interface member replaces the base member instead of merging with it, and must be assignable to it: declaring only the $entity form gives TS2430: Interface 'Querier' incorrectly extends 'UniversalQuerier' and breaks findMany(Entity, {…}) everywhere.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions