Remote / foldkit-remote-drizzle

Query connections

Top-level Remote Queries compile to keyset pagination:

ts
import { eq } from 'drizzle-orm'
import { Schema } from 'effect'
import { Query } from 'foldkit-remote'
import { query } from 'foldkit-remote-drizzle'

const ProjectsByOwner = Query.make('ProjectsByOwner', {
  Input: Schema.Struct({ ownerId: Schema.String }),
  Result: Query.connection(Project),
})

const ProjectsByOwnerSource = query(ProjectsByOwner, {
  entity: Project,
  orderBy: [
    { column: projects.createdAt, direction: 'desc' },
    { column: projects.id, direction: 'desc' },
  ],
  where: input => eq(projects.ownerId, input.ownerId),
})

query takes no type arguments. The input is the descriptor's, and a source whose where or orderBy reads nothing of the principal fits any server's. A source that does read it says so on the parameter, where: (input, principal: Principal) => …, and is then a source for a server of that Principal only; a server with another principal refuses it at the type.

A query that carries its own meaning

When the descriptor was declared with Query.define, its body already says which rows and in what order. Register the source and say no more:

ts
import { Expr, Order, Query } from 'foldkit-entity'

const PostsBySlug = Query.define('PostsBySlug', { slug: Schema.String }, ({ input }) =>
  Query.from(Post).pipe(
    Query.where(Expr.eq(Post.fields.slug, input.slug)),
    Query.orderBy(Order.asc(Post.fields.id)),
  ),
)

const PostsBySlugSource = query(PostsBySlug, { entity: PostBinding })

The binding is what knows which column holds which field, so nothing in the body names a table and the same body could be compiled by something else. Every field the body reads — in a predicate or in its ordering — is checked when the source is registered, so a server that starts is a server whose queries can be answered, rather than one that fails on whichever request first runs this query.

The body's order is tie-broken by the id. A body says what the rows mean, not how a cursor walks them, so an order that does not already end on the id gets it appended, exactly as a computed orderBy does. A literal orderBy written here is this binding's own and stays as given.

Three things are worth being exact about:

  • A body never widens what a principal may see. The compiled predicates, this server's own where, and the binding's visible rule are conjoined. A body is the application's question; authorization stays where it was.
  • A where given here is added to the body, not put in its place. A binding can narrow a query it did not write.
  • An orderBy given here does replace the body's, because ordering is one decision and a connection pages on exactly one of them.

Query.make is unchanged and still the right declaration when the meaning of a query lives on this side; such a descriptor has no body, and orderBy is then required as before.

Think of the pieces as:

Remote Query descriptor
  input + result entity
          |
          v
Drizzle query source
  where + stable total order
          |
          v
QueryPage
  edges + explicit boundaries

orderBy must be non-empty and should form a stable total order. Add a unique tie-breaker such as the id.

Sorting by what the user chose

Put the sort in the query's body, where the client reads it too:

ts
const PostsQuery = Query.define('Posts', { sort: PostSort.Schema }, ({ input }) =>
  Query.from(Post).pipe(
    Query.orderBy(Order.chosen(input.sort, { title: Post.fields.title, created: Post.fields.createdAt })),
  ),
)
query(PostsQuery, { entity: Db.Post }) // the body's order, resolved per request

The compiler resolves the chosen field for each request's input and ends the order on the id. An input naming no order the body offers is refused as a query error, the request's mistake. A body that declares its order refuses an orderBy here: two orders would let the server sort one way while the client places rows another.

A Query.make descriptor has no body, so its orderBy may read the query's input, as where does:

ts
query(PostsQuery, {
  entity: Db.Post,
  where: ({ search }) => (search === '' ? undefined : like(posts.title, `%${search}%`)),
  orderBy: ({ sort }) =>
    sort === 'title'
      ? [{ column: posts.title, direction: 'asc' }]
      : [{ column: posts.id, direction: 'asc' }],
})
  • sortTerms(sort, { title: posts.title, created: posts.createdAt }) turns the state foldkit-crud's Sort makes ({ by, direction }, or null) into order terms: orderBy: ({ sort }) => sortTerms(sort, { ... }). A name the map lacks, or no sort, is no terms, which orders by id.
  • The input should name an order ('title'), never a column. With Order.chosen the body maps each name to a field; with sortTerms the server does.
  • The input is part of a connection's identity, so each order is its own connection with its own cursors. A cursor from one order never pages another.
  • A computed order that leaves the id out is tie-broken by it, ascending, since what a user sorts by is rarely unique. An empty computed order is the id's.
  • A body term's collation is compiled with it, in the ORDER BY and the keyset alike: binary as COLLATE BINARY (SQLite) or COLLATE "C" (Postgres, text columns only; a uuid takes none and orders by bytes), asciiFold as COLLATE NOCASE or lower(x COLLATE "C") (folded under "C", so only ASCII letters fold), and a locale as COLLATE "name" on Postgres; SQLite, which has no locales, refuses one.
  • Where rows without a value go is said on every term and compiled as NULLS FIRST/NULLS LAST, so a nullable column sorts and pages the same on SQLite and Postgres. The default is Postgres's (last ascending, first descending); a body's term says otherwise with Order.asc(field, { nulls: 'first' }), and an orderBy term here with nulls: 'first'.

A page's cursor is where it ended: the order's key values at its last row, the id among them (k:[...]). The next page's keyset predicate is built from those values, so "load more" carries on after the last row read was deleted, stopped matching, or moved, from where the page ended. A cursor is client input, and is refused unless it holds exactly one key value (text, a number, a boolean, null, or a tagged date or bigint) for each term of the order it pages. A cursor from before keys is a row id, still read by re-reading that row's keys; one whose row is gone fails rather than restarting at page one.

A key cursor carries those values to the client as they are stored. Ordering already tells a reader how rows compare by a field; the cursor tells them the value at each page's end. Do not offer an order (Order.chosen) by a field some reader may not read.

Client-supplied windows are bounded: the default page size is 20, the default maximum is 100, and malformed combinations such as after + before or first + last fail rather than being guessed.

Computed fields

The built-in computed field is a count over a collection relation:

ts
const Post = entity('Post', posts, {
  relations: {
    comments: many(Comment, {
      foreignKey: comments.postId,
      localKey: posts.id,
    }),
  },
  computed: {
    commentCount: { relation: 'comments' },
  },
})

commentCount becomes a Remote number field. The Source runs a grouped count(*), including the relation's static where and principal policy. The count is the total relation count, not the current page count.

Other aggregate shapes are intentionally not a generic compiler yet.