Remote / foldkit-remote-drizzle
Query connections
Top-level Remote Queries compile to keyset pagination:
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:
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'svisiblerule are conjoined. A body is the application's question; authorization stays where it was. - A
wheregiven here is added to the body, not put in its place. A binding can narrow a query it did not write. - An
orderBygiven 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 boundariesorderBy 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:
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 requestThe 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:
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 statefoldkit-crud'sSortmakes ({ by, direction }, ornull) 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. WithOrder.chosenthe body maps each name to a field; withsortTermsthe 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 BYand the keyset alike:binaryasCOLLATE BINARY(SQLite) orCOLLATE "C"(Postgres, text columns only; auuidtakes none and orders by bytes),asciiFoldasCOLLATE NOCASEorlower(x COLLATE "C")(folded under"C", so only ASCII letters fold), and a locale asCOLLATE "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 withOrder.asc(field, { nulls: 'first' }), and anorderByterm here withnulls: '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:
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.