npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@davidtkramer/convex-relations

v0.4.2

Published

Typed query facade helpers for Convex backends

Readme

convex-relations is a server-side query facade for Convex. It lets you write data loading code as a typed result tree instead of manually coordinating lookups, parallelization, and response shaping by hand.

Example

With convex-relations, a nested API-ready query can look like this:

import { query } from "./_generated/server";

export const getPost = query({
  args: {},
  handler: async (ctx) => {
    const post = await ctx.q.posts
      .bySlug("hello-world")
      .with((post) => ({
        author: ctx.q.authors.find(post.authorId),
        recentComments: ctx.q.comments
          .byPostId(post._id)
          .order("desc")
          .with((comment) => ({
            author: ctx.q.authors.find(comment.authorId),
          }))
          .take(10),
        categories: ctx.q.categories
          .through(ctx.q.postCategories.byPostId(post._id), "categoryId")
          .many(),
      }))
      .unique();

    // post.author is an author document
    console.log(post.author.name);

    // post.recentComments is a list of comments with nested authors
    console.log(post.recentComments[0]?.author.name);

    // post.categories is already shaped as related category documents
    console.log(post.categories.map((category) => category.slug));

    return post;
  },
});

This example shows the core model:

  • table-scoped access through q.posts, q.comments, and q.categories
  • indexes as first-class query methods like .bySlug(...) and .byPostId(...)
  • nested relation expansion with .with(...) inside .with(...)
  • reference traversal with .through(...)
  • parallel nested loading within each with(...)
  • a final strongly typed, API-ready result from one expression

Equivalent Convex Code

Without convex-relations, you end up assembling the same result shape by hand (or by agent 😉):

const post = await ctx.db
  .query("posts")
  .withIndex("bySlug", (q) => q.eq("slug", args.slug))
  .unique();

if (!post) {
  throw new Error("Post not found");
}

const [author, recentComments, postCategoryLinks] = await Promise.all([
  ctx.db.get(post.authorId),
  ctx.db
    .query("comments")
    .withIndex("byPostId", (q) => q.eq("postId", post._id))
    .order("desc")
    .take(10),
  ctx.db
    .query("postCategories")
    .withIndex("byPostId", (q) => q.eq("postId", post._id))
    .collect(),
]);

const recentCommentsWithAuthors = await Promise.all(
  recentComments.map(async (comment) => ({
    ...comment,
    author: await ctx.db.get(comment.authorId),
  })),
);

const categories = (
  await Promise.all(
    postCategoryLinks.map((link) => ctx.db.get(link.categoryId)),
  )
).filter((category) => category !== null);

return {
  ...post,
  author,
  recentComments: recentCommentsWithAuthors,
  categories,
};

That works, but you are responsible for:

  • deciding what should run in parallel
  • remembering to manually Promise.all(...) nested relationships
  • traversing join tables by hand
  • assembling the final tree shape yourself for API responses

Table of Contents

Installation

npm install @davidtkramer/convex-relations
pnpm add @davidtkramer/convex-relations
bun add @davidtkramer/convex-relations
yarn add @davidtkramer/convex-relations

Quick Start

Most apps expose the facade on ctx.q through convex-helpers custom function wrappers. A minimal setup looks like this:

// convex/lib/functions.ts
import { customCtx, customQuery } from "convex-helpers/server/customFunctions";
import { query as baseQuery } from "./_generated/server";
import type { DataModel } from "./_generated/dataModel";
import schema from "../schema";
import { createQueryFacade } from "@davidtkramer/convex-relations";

export const query = customQuery(
  baseQuery,
  customCtx((ctx: { db: any }) => ({
    q: createQueryFacade<DataModel>(ctx.db, schema),
  })),
);

Once you do that, usage looks like this:

const post = await ctx.q.posts
  .bySlug("hello-world")
  .with((post) => ({
    author: ctx.q.authors.find(post.authorId),
    comments: ctx.q.comments.byPostId(post._id).order("desc").take(10),
  }))
  .unique();

Core Concepts

Table namespaces

Every table becomes a namespace on the returned facade:

await ctx.q.posts.many();
await ctx.q.authors.bySlug("ada-lovelace").unique();
await ctx.q.comments.byPostId(postId).order("desc").take(20);

Indexes become methods

For example, imagine these index definitions:

authors: defineTable({
  slug: v.string(),
  name: v.string(),
}).index("bySlug", ["slug"]);

comments: defineTable({
  postId: v.id("posts"),
  authorId: v.id("authors"),
  status: v.union(v.literal("pending"), v.literal("approved")),
  body: v.string(),
})
  .index("byPostId", ["postId"])
  .index("byPostIdAndStatus", ["postId", "status"]);
const author = await ctx.q.authors.bySlug("ada-lovelace").unique();
const comments = await ctx.q.comments.byPostId(postId).many();
const approvedComments = await ctx.q.comments
  .byPostIdAndStatus(postId, "approved")
  .many();

Single-field indexes accept a scalar. Compound indexes accept positional arguments in index order. Zero-argument calls give you the indexed range so you can filter, sort, paginate, or take a subset.

Because shorthand index methods like .bySlug("...") and .byPostIdAndStatus(...) need the real indexed field names at runtime, createQueryFacade(...) must be constructed with your Convex schema.

with(...) builds nested result shapes

with(...) lets you attach additional fields to every document in a query. The callback receives the current document plus a small helper context, and returns an object whose values can be plain sync values, other query nodes, or deferred work via defer(...).

const post = await ctx.q.posts
  .bySlug("hello-world")
  .with((post, { defer }) => ({
    author: ctx.q.authors.find(post.authorId),
    comments: ctx.q.comments.byPostId(post._id).take(10),
    readingTimeMinutes: defer(() =>
      Math.ceil(post.body.split(/\s+/).length / 200),
    ),
  }))
  .unique();

That returns a single object shaped like:

{
  ...post,
  author,
  comments,
}

This is the core idea behind the library: compose the data you want as a tree, and convex-relations resolves and assembles that tree for you.

Within a single with(...), sibling fields are resolved in parallel. If you attach both author and comments, those branches start loading at the same time. Nested with(...) calls preserve that behavior recursively, so each level of the result tree parallelizes across its sibling fields.

API

createQueryFacade<DataModel>(db, schema)

Creates a typed facade over your Convex db. Pass the runtime schema from defineSchema(...) so indexed shorthand methods can map scalar and tuple arguments onto the correct Convex index fields.

import { createQueryFacade } from "@davidtkramer/convex-relations";
import type { DataModel } from "./_generated/dataModel";
import schema from "../schema";

const q = createQueryFacade<DataModel>(ctx.db, schema);

with(..., { defer })

The with(...) callback context includes defer, which wraps arbitrary async or sync work into the same lazy result tree as your relation queries.

const post = await q.posts
  .bySlug("hello-world")
  .with((post, { defer }) => ({
    readingTimeMinutes: defer(() =>
      Math.ceil(post.body.split(/\s+/).length / 200),
    ),
  }))
  .unique();

Table Access Patterns

find(id) and findOrNull(id)

Direct _id lookup.

const post = await q.posts.find(postId);
const maybePost = await q.posts.findOrNull(postId);

find(...) throws if the document is missing. findOrNull(...) returns null.

Full table or index range queries

Zero-argument table or index access creates a range query.

const latestPosts = await q.posts.order("desc").take(20);

const authorPosts = await q.posts
  .byAuthorId()
  .filter((query) => query.eq(query.field("authorId"), authorId))
  .order("desc")
  .many();

Indexed lookup by value

Single-field indexes accept a scalar:

const author = await q.authors.bySlug("ada-lovelace").unique();

Compound indexes accept leading positional arguments:

const comments = await q.comments
  .byPostIdAndStatus(postId)
  .order("desc")
  .take(20);

const exactOrPrefix = await q.comments
  .byPostIdAndStatus(postId, "approved")
  .many();

Indexed lookup by selector function

You can also pass Convex's index selector callback:

const approvedComments = await q.comments
  .byPostIdAndStatus((q) => q.eq("postId", postId).eq("status", "approved"))
  .many();

Batch lookup with .in(...)

Available on _id and indexed entrypoints.

const posts = await q.posts.in(postIds).many();

const categories = await q.categories.bySlug
  .in(["typescript", "convex"])
  .many();

Batch lookups skip missing rows.

Relation Expansion with with(...)

with(...) lets you attach related data or computed fields before a terminal.

const post = await q.posts
  .bySlug("hello-world")
  .with((post, { defer }) => ({
    author: q.authors.find(post.authorId),
    comments: q.comments.byPostId(post._id).order("desc").take(10),
    commentCount: defer(async () => {
      const comments = await q.comments.byPostId(post._id).many();
      return comments.length;
    }),
  }))
  .unique();

You can chain with(...) calls:

const post = await q.posts
  .bySlug("hello-world")
  .with((post) => ({
    author: q.authors.find(post.authorId),
  }))
  .with((post) => ({
    otherPostsByAuthor: q.posts.byAuthorId(post.author._id).many(),
  }))
  .unique();

Each with(...) stage sees fields added by earlier stages.

Reference Traversal with through(...)

Use through(sourceQuery, foreignKeyField) when another query already produces rows that point at the table you want.

Given postCategories { postId, categoryId }, you can fetch categories for a post:

const categories = await q.categories
  .through(q.postCategories.byPostId(postId), "categoryId")
  .many();

This is essentially syntactic sugar for "run the source query, extract ids from that field, then load the target rows for you."

You can also attach the source row through the normal with(...) callback:

const categories = await q.categories
  .through(q.postCategories.byPostId(postId).order("desc"), "categoryId")
  .with((category, { source }) => ({ link: source }))
  .many();

categories[0]?.link.postId;
categories[0]?.link.categoryId;

This is useful when the source table stores metadata like ordering, role, or timestamps.

through(...) is not limited to join tables. Any compatible source query works:

const author = await q.authors
  .through(q.posts.bySlug("hello-world"), "authorId")
  .with((author, { source }) => ({ post: source }))
  .unique();

author.post.slug;

Source-query shaping lives inside the through(...) argument:

const tags = await q.tags
  .through(
    q.postTags
      .byPostId(postId)
      .filter((query) => query.eq(query.field("kind"), "primary"))
      .order("desc")
      .take(10),
    "tagId",
  )
  .with((tag, { source }) => ({ link: source }))
  .many();

After through(...), you can keep shaping the target result with with(...) and then choose a terminal like many() or first().

Terminals

unique() / uniqueOrNull()

Use when the query should match at most one document.

const author = await q.authors.bySlug("ada-lovelace").unique();
const maybeAuthor = await q.authors.bySlug("missing").uniqueOrNull();

first() / firstOrNull()

Use when you want the first result from an ordered or filtered range query.

const latestComment = await q.comments.byPostId(postId).order("desc").first();
const maybeLatestComment = await q.comments
  .byPostId(postId)
  .order("desc")
  .firstOrNull();

many()

Collects all matching rows.

const comments = await q.comments.byPostId(postId).many();

take(count)

Collects up to count rows.

const comments = await q.comments.byPostId(postId).order("desc").take(20);

paginate(opts)

Returns Convex-style pagination output.

const page = await q.posts.byAuthorId(authorId).paginate({
  cursor: null,
  numItems: 25,
});

Error Semantics

  • find(...) throws if the document is missing
  • unique() throws if there is no match
  • unique() also throws if there are multiple matches
  • first() throws if there is no match
  • findOrNull(), uniqueOrNull(), and firstOrNull() return null instead

Performance Characteristics

What runs in parallel

Within a single with(...) stage, every field in the returned object runs in parallel.

const post = await q.posts.find(postId).with((post, { defer }) => ({
  author: q.authors.find(post.authorId),
  comments: q.comments.byPostId(post._id).take(10),
  categoryCount: defer(async () => {
    const categories = await q.categories
      .through(q.postCategories.byPostId(post._id), "categoryId")
      .many();
    return categories.length;
  }),
}));

Those three branches are executed concurrently.

For collection queries, expansion also runs in parallel across items:

  • the query fetches the base rows
  • each row is expanded concurrently
  • each field inside a single expansion stage is also concurrent

What runs sequentially

Chained with(...) stages are sequential by design.

q.posts
  .with((post) => ({ author: q.authors.find(post.authorId) }))
  .with((post) => ({ otherPosts: q.posts.byAuthorId(post.author._id).many() }));

The second stage waits for the first stage, because it depends on fields added earlier.

through(...) currently resolves target documents by fetching the source rows first, then loading each target document individually. This is correct and predictable, but it is not a single batched join at the database level.

Practical guidance

  • Prefer one with(...) stage when fields are independent
  • Split into multiple with(...) stages only when later fields depend on earlier expansions
  • Use take(...) or paginate(...) instead of many() on large collections
  • Use indexed entrypoints whenever possible
  • Use .in(...) when you already have a set of ids or indexed values

Comparison to convex-helpers/server/relationships

If you started from Convex relationship helpers like getOneFrom, getManyFrom, or getManyVia, this library aims to provide the same kind of relational navigation with better composition.

This:

const categories = await q.categories
  .through(q.postCategories.byPostId(postId), "categoryId")
  .many();

replaces patterns like:

const categories = await getManyVia(
  db,
  "postCategories",
  "categoryId",
  "postId",
  postId,
);

but also composes naturally with with(...), take(...), firstOrNull(), and typed nested traversal.