Joist 2.3 Released with Scopes!
Joist 2.3 is out! (…as of a month ago, we’re behind on the blog post! 😅)
The release highlights are:
- Rails-style
scopes for naming and composing reusableem.findfilters - Scaffolding for GraphQL query resolvers
- Paginated
em.findcalls are now auto-batched - New
EnumCollections for many-to-many join tables that point at an enum - Native
Temporalsupport on Node 26, with no polyfill required - Smarter batched queries via constant inlining and boolean pruning
- A large round of hot-path performance work, with 20-50%+ speedups across flush, hydration, and
find
Rails-Style Scopes
Section titled “Rails-Style Scopes”The headline 2.3 feature is #1892: Rails-style scopes 🎉, which let you name and compose common em.find filters directly on your entity classes.
You declare scopes as static fields, using the scope function:
export class Author extends AuthorCodegen { static adult = scope({ age: { gte: 18 } }); static active = scope({ deletedAt: null }); static popular = scope((a) => a.isPopular.eq(true)); static hasBooks = scope({ books: true }); static booksReviewedBy = scope.fn((reviewer: Author) => ({ books: { reviewer } }));}And then reuse and chain them throughout your codebase:
await Author.adult.find(em);await Author.adult.popular.find(em);await Author.active.where({ firstName: "a1" }).find(em);await Author.booksReviewedBy(reviewer).find(em);Scopes are syntax-sugar for Joist’s regular em.find, so they share the same filter syntax and semantics, including relation filters across one-to-many, many-to-one, and many-to-many paths.
A few things scopes can do:
- Chain with
ANDsemantics, i.e.Author.adult.popularfinds authors that are both, and you can define a scope in terms of another:static popularAdult = Author.popular.adult. - Take parameters via
scope.fn, i.e.Author.named("a")orAuthor.taggedWith("fiction"). - Add ad-hoc builders, i.e.
Author.adult.orderBy({ createdAt: "DESC" }).limit(10)or.softDeletes("include"). - Drop into
em.findanywhere a filter is expected, i.e.em.find(Author, Author.adult)orem.find(Book, { author: Author.adult }).
Because they compile down to regular em.find queries, scopes also support join-alias conditions for predicates a plain nested filter can’t express, like a top-level or that spans two different joined tables:
export class Author extends AuthorCodegen { // Authors with a book that is either titled "b1" or has a 3-star review. static titleOrRated = scope((a) => { const [b, r] = aliases(Book, BookReview); return { where: { books: { as: b, reviews: { as: r } } }, conditions: { or: [b.title.eq("b1"), r.rating.eq(3)] }, }; });}Joist’s scopes are heavily inspired by Rails scopes 🙏, but are strongly-typed and adapted to fit Joist’s conventions–most notably, scopes always require a trailing .find(em) (or findOne, findOneOrFail, findCount, findIds) so Joist knows which EntityManager to use for loading and identity caching.
See the new Scope Queries docs for the full feature set.
GraphQL Query Scaffolding
Section titled “GraphQL Query Scaffolding”Joist has had scaffolding for GraphQL object resolvers & save mutations for awhile now, but we finally got around to adding query scaffolding in #1865.
This means that after adding your authors database table, Joist will (if you’ve enabled the GraphQL scaffolding) codegen not only an Author.ts and AuthorCodegen.ts file, but also will auto-create a schema/Author.graphql file with a basic Author type and a Query extension for fetching authors:
extend type Query { " New in 2.3 " author(id: ID!): Author! " New in 2.3 " authors(filter: AuthorFilter, limit: Int, offset: Int): AuthorsPage!}
extend type Mutation { " Already scaffolded in earlier releases. " saveAuthor(input: SaveAuthorInput!): Author!}As well as default implementations of the authorQuery.ts and authorsQuery.ts files:
export const author: Pick<QueryResolvers, "author"> = { async author(_, args, ctx) { return ctx.em.load(Author, args.id); },};export const authors: Pick<QueryResolvers, "authors"> = { async authors(_, args, ctx) { return paginate(ctx, Author, args); },};As always, if you need to change any of the scaffolded output, just update it!
Joist will never overwrite your changes.
Batched Paginated Finds
Section titled “Batched Paginated Finds”Joist’s em.find has always auto-batched its SELECT queries (part of our N+1 prevention), but previously em.find did not support batching paginated queries, i.e. queries wanting to use OFFSET or LIMIT.
This limitation was not an issue for top-level endpoints doing pagination, like GET /authors?offset=100&limit=50 or query / authors(offset: 100), but it does become an issue if you want to return “only the first N grandchildren” in a nested child, like this GraphQL query:
query { authors(filter: { status: "active" }) { books { " The `reviews` field will invoked once per author, per book " reviews(first: 5) { rating } } }}This reviews(first: 5) is basically “a paginated SELECT in a loop”, which now in #1836 will also be batched into a single SQL query for all children invoking the query.
Because em.find now supports limit and offset, we don’t have a need for legacy em.findPaginated method, which was the previous way to do (unbatched) paginated finds, anymore, so it’s been removed.
Now any em.find can accept limit/offset and still get Joist’s auto-batching:
const [aReviews, bReviews] = await Promise.all([ em.find(BookReview, { book: b1 }, { limit: 5, orderBy: { id: "ASC" } }), em.find(BookReview, { book: b2 }, { limit: 5, orderBy: { id: "ASC" } }),]);We covered this in detail in its own Batched Paginated Finds post.
Enum Collections
Section titled “Enum Collections”Joist has historically supported modeling “lists of enums” via int[] enum-array columns, but #1908 adds a second option: a true many-to-many join table between an entity and an enum, modeled as an EnumCollection.
For example, to give Publisher a list of “logo colors”, where Color is an enum in your domain model, create a join table between publishers and the color enum table:
export function up(b: MigrationBuilder): void { createManyToManyTable(b, "publisher_logo_colors", "publishers", {table: "color", column: "logo_color_id"});}Joist recognizes that one side points at an enum table and generates an EnumCollection on the entity side (only the entity side; the enum does not get a reverse relation):
// Code generatedpublic class PublisherCodegen extends BaseEntity { readonly logoColors: EnumCollection<Publisher, Color> = hasEnumCollection();}To the caller it looks like “an array of Colors”, but unlike an enum-array column it’s lazy (because it lives in its own table, it’s not immediately fetched as part of the SELECT * FROM publishers call, so it needs a load hint), and it’s fully integrated with the rest of Joist:
const publisher = await em.load(Publisher, "p:1", "logoColors");publisher.logoColors.get; // [Color.Red, Color.Blue]publisher.logoColors.add(Color.Green);publisher.logoColors.set([Color.Green, Color.Blue]);await publisher.logoColors.includes(Color.Blue); // probe without loadingIt supports filtering (em.find(Publisher, { logoColors: Color.Red })), changes tracking, and reactivity, just like any other collection. See the Enum Collections docs for when to reach for this versus a plain enum-array column.
Native Temporal on Node 26
Section titled “Native Temporal on Node 26”Joist supports the Temporal API for date/time columns, and historically required the temporal-polyfill package to provide it.
Node 26 ships Temporal natively, so #1889 updates Joist’s native-first / polyfill-fallback detection to transparently use the global Temporal when it’s available, falling back to the polyfill on older Node versions. There’s also a new import { Temporal } from "joist-orm" that resolves to whichever implementation is in use, so codegen and app code don’t need a direct import to either.
Optimized Batched Queries
Section titled “Optimized Batched Queries”Two changes make Joist’s batched em.find queries leaner for the Postgres planner.
#1909 adds constant inlining. Previously, every filter value in a batch flowed through the _find CTE, even columns whose value was identical across all the batched calls. Now Joist detects when all batched queries share the same value for a column and inlines it as a plain bound predicate, leaving only the genuinely-varying columns in the CTE:
WITH _find (tag, arg0) AS ( SELECT unnest($1::int[]), unnest($2::character varying[]))SELECT array_agg(_find.tag) as _tags, a.*FROM authors AS aCROSS JOIN _find AS _findWHERE a.deleted_at IS NULL AND a.first_name = $3 AND a.last_name = _find.arg0GROUP BY a.idHere first_name = $3 is a plain scalar comparison the planner can satisfy with an index, while only last_name–the column that actually differs between the batched queries–flows through the CTE.
#1879 adds boolean expression pruning, so a common conditional-filter idiom just works:
em.find(Author, { name: { ilike: name && `${name}%` },});When name is falsy, the ilike: false term is pruned from the query entirely (rather than generating a spurious predicate), while equality operators like eq: false are preserved.
Many-to-Many Tables Without id Columns
Section titled “Many-to-Many Tables Without id Columns”#1904 lets Joist work with many-to-many join tables that don’t have a surrogate id primary key, i.e. tables whose primary key is just the (foo_id, bar_id) pair. This is a common shape for join tables, and Joist now handles loading, preloading, and mutating their rows just like the id-backed variant.
Performance Work
Section titled “Performance Work”2.3 includes a large round of hot-path performance work, benchmarked at 100k-entity scale across several independent process runs. The headline numbers (vs. the prior commit, all confirmed):
- Flush scanning (#1857): with a mostly-clean unit of work, scanning for dirty entities went from
~4msto~0.1msfor the 0-dirty case (~97%), and~9.5msto~4.8mswith 100 dirty entities (~49%). - Entity writer binding (#1857): insert-heavy flushes dropped
~28-47%depending on column count. - Lazy find indexes (#1861): in-memory indexes are now lazy, and only triggered when fields are actually queried, which took a first-build scenario from
~1517msto~24ms(~98%), with30-50%steady-state wins. - Find filter cache keys (#1859): when deduping queries for N+1 prevention, we avoid re-JSON.stringify-ing keys, which dropped high-cost deduping by
~52-58%. - Reactive queue lookups (#1857):
~22-43%faster. - Entity hydration and identity-map
loadAllpaths:~17-34%faster. - Already-loaded
populatepaths:~22-33%faster.
There’s also a new BENCHMARK-PLAN.md with the methodology and a regression-benchmark suite to keep these from regressing.
Smaller But Notable Changes
Section titled “Smaller But Notable Changes”A few other changes worth calling out:
- #1900 aligns many-to-one and polymorphic behavior on empty arrays,
em.find({ publisher: [] })no always meansin: []which means “no rows”. - #1883 excludes
em.deletedentities fromfindCount, and #1871 ensuresfinddoes not return pending-delete entities. - #1881 always defines
transientFields, which is still only instantiated lazily, but useful for metaprogramming to rely on it. - #1874 caches dataloader lookups/promises during
em.populate, and #1868 avoids recursive-property populate deadlocks. - #1873 handles cycles in recursive Properties.
- #1891 skips preloading GraphQL fields with arguments.
- #1863 restores
em.flushreturn order, and #1862 keepsloadLenssynchronous for already-loaded paths.
There are also many correctness fixes around STI subtypes, recursive relations, reaction handling, and dependency bumps, plus codegen conflict-avoidance fixes (#1901, #1902, #1903) to keep generated scope/enum/type names from colliding with user symbols.
Upgrading
Section titled “Upgrading”The main migration notes are:
- Replace
em.findPaginatedcalls withem.find, passing the samelimit/offsetoptions. - After adding any
scopedeclarations, re-runjoist-codegento refresh the generated<Entity>Scopestypes.
Thanks to everyone filing issues, testing next releases, and pushing on Joist’s edge cases.

