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

@iappx/entity-repo-query

v1.1.1

Published

A transport agnostic query AST for @iappx/entity-repo: build filters, ordering, paging and selection once, compile them with any dialect.

Readme

@iappx/entity-repo-query

npm CI license node

Write a query once — compile it for REST, GraphQL, SQL or anything else.

An add-on for @iappx/entity-repo that turns a typed builder chain into a plain JSON description of the query. A dialect then turns that description into a real request.

npm install @iappx/entity-repo-query

The packages

This one describes the query. It never sends anything — a dialect package does that, and two are already written:

| Package | What it is | | --- | --- | | @iappx/entity-repo | The core. Entities, metadata, change tracking, contexts, transport interfaces. Everything below builds on it. | | @iappx/entity-repo-query (this one) | The typed builder and the query AST, plus QueryableEntityQuery and the capability validator every dialect checks against. | | @iappx/entity-repo-rest | The REST dialect: the AST becomes an HTTP request. Nine swappable strategies, a fetch transport included. | | @iappx/entity-repo-gql | The GraphQL dialect: the AST becomes a document — queries, mutations and subscriptions — with no schema and no codegen. | | @iappx/gql-builder | The dependency-free document builder the GraphQL dialect prints through. Useful on its own. |

The Writing a dialect section below is for a data source neither dialect covers.


How it works

The package sits exactly in the middle: your code says what it wants, the dialect decides how to ask for it.

   your application            this package                a dialect                the wire
 ┌───────────────────┐   ┌──────────────────────┐   ┌──────────────────────┐   ┌──────────────┐
 │ .where(f => …)    │   │ TQueryAst            │   │ IQueryCompiler       │   │ GET /accounts│
 │ .orderBy('name')  │ → │ plain JSON, no       │ → │ + capability check   │ → │ ?status[eq]= │
 │ .take(20)         │   │ classes, no network  │   │ + path/value mapping │   │ &limit=20    │
 └───────────────────┘   └──────────────────────┘   └──────────────────────┘   └──────────────┘
      typed by your            serialisable,             REST · GraphQL ·
      entity classes           cacheable, testable       SQL · in-memory

Swap the dialect and not a single line of application code changes.

Highlights

| | | | --- | --- | | 🔒 Typed end to end | Field names and value types are checked against your entity. f.eq('status', 'activ') is a compile error, not a 400 at runtime. | | 📦 The query is data | The AST is plain objects with a kind tag — hash it into a cache key, store it, snapshot-test it, send it over a worker boundary. | | 🧊 Immutable builders | Every call returns a new query, so a "base query" can be shared as a preset and branched safely. | | 🚦 Dialects declare their limits | A REST backend that has no OR? The query fails with a readable list of reasons before the request leaves the process. | | 🗺️ Names are resolved for you | createdAt becomes created_at, relation paths are walked, a typo raises UnknownFieldError instead of silently vanishing. | | 🌳 Selection that can't explode | Field sets expand from entity metadata with a depth limit and cycle detection — self-referencing models terminate. | | 🧩 Visitor-based dialects | Extend FilterVisitor and implement four methods; a new node kind can't be silently forgotten. | | 🚪 Escape hatches everywhere | Raw conditions, custom operators, per-field and per-query metadata for the things no AST will ever cover. | | 🪶 No runtime dependencies | Only @iappx/entity-repo as a peer. Nothing here opens a socket. |

Requirements

  • Node.js >= 18
  • TypeScript with experimentalDecorators
  • @iappx/entity-repo >= 3.2.0 (peer dependency)

Quick start

1. Describe an entity

Fields live on the prototype as accessors, so under strict they need a definite assignment assertion (!):

import { RepoEntityBase, RepoEntityField } from '@iappx/entity-repo'

export class Account extends RepoEntityBase<Account> {
    @RepoEntityField({ isPrimaryKey: true, isGenerated: true })
    public id!: string

    @RepoEntityField()
    public email!: string

    @RepoEntityField()
    public status!: 'active' | 'banned'

    @RepoEntityField({ isReadonly: true })
    public createdAt!: string

    @RepoEntityField()
    public bannedAt!: string | null

    @RepoEntityField({ nestedType: () => Book, isArray: true })
    public books!: Book[]
}

2. Build a query

import { QueryBuilder } from '@iappx/entity-repo-query'

const ast = QueryBuilder.create<Account>()
    .where(f => f.and(
        f.eq('status', 'active'),
        f.gte('createdAt', '2026-01-01'),
    ))
    .orderBy('createdAt', 'desc')
    .take(20)
    .toAst()

ast is now this — nothing more, nothing less:

{
  "filter": {
    "kind": "logical",
    "operator": "and",
    "nodes": [
      { "kind": "comparison", "path": ["status"], "operator": "eq", "value": "active" },
      { "kind": "comparison", "path": ["createdAt"], "operator": "gte", "value": "2026-01-01" }
    ]
  },
  "order": [{ "path": ["createdAt"], "direction": "desc" }],
  "paging": { "kind": "offset", "limit": 20 }
}

3. Query through the entity context

In practice you rarely touch QueryBuilder directly: a dialect package — @iappx/entity-repo-rest or @iappx/entity-repo-gql — extends QueryableEntityQuery, and the same methods are available straight on the entity set.

const page = await context.accounts
    .where(f => f.eq('status', 'active'))
    .orderBy('createdAt', 'desc')
    .take(20)
    .getPage()

getAll() / getPage() come from the transport package — this one only supplies the query part and the TPage<T> shape (items, total?, cursor?).


Building queries

Filters

The callback of where receives a factory typed by the entity, so both the field name and the value type are checked:

query.where(f => f.and(
    f.eq('status', 'active'),
    f.in('id', ['1', '2']),
    f.gte('createdAt', '2026-01-01'),
    f.not(f.contains('email', '+test')),
    f.any('books', b => b.gte('rating', 4)),   // "has at least one book rated 4+"
))

| Group | Methods | | --- | --- | | Comparison | eq ne gt gte lt lte in notIn between isNull | | Text | contains startsWith endsWith | | Logical | and or not | | Relations | on (to-one) · any all none (collections) | | Escape | op opPath raw |

and / or with a single condition return that condition unchanged, so the AST never fills up with one-child wrappers.

Reuse conditions as named business rules. A filter is just a function of the factory:

export class AccountFilters {
    public static active(f: FilterFactory<Account>): TFilterNode {
        return f.and(f.eq('status', 'active'), f.isNull('bannedAt'))
    }
}

query.where(f => AccountFilters.active(f))

Ordering

query.orderBy('createdAt', 'desc')          // repeatable — orderings append
query.orderBy('email', 'asc', 'last')       // null placement
query.orderByPath(['profile', 'city'])      // through a relation
query.clearOrder()                          // QueryBuilder only

Paging

Three kinds, mutually exclusive. take adapts to whichever kind is already set; mixing an offset with a cursor throws QueryBuildError instead of producing something the backend would misread.

query.take(20).skip(40)     // { kind: 'offset', limit: 20, offset: 40 }
query.page(2, 25)           // { kind: 'page',   number: 2,  size: 25 }
query.take(10).after('c')   // { kind: 'cursor', first: 10,  after: 'c' }
query.take(10).before('c')  // { kind: 'cursor', last: 10,   before: 'c' }

Selection and relations

Without a selection the dialect takes every field the entity declares (minus isClientOnly ones). select narrows it, include pulls in a relation with a query of its own:

query
    .select(s => s.only('id', 'email'))
    .include('books', q => q
        .where(f => f.gte('rating', 4))
        .orderBy('rating', 'desc')
        .take(3))
    .exclude('createdAt')

Branching a query

Builders are immutable, so a prepared query is a safe starting point:

const active = QueryBuilder.create<Account>()
    .where(f => AccountFilters.active(f))
    .orderBy('createdAt', 'desc')

const firstPage = active.take(20).toAst()
const secondPage = active.take(20).skip(20).toAst()   // `active` is untouched

The AST

type TQueryAst = {
    selection?: TSelectionAst
    filter?: TFilterNode
    order?: TOrderNode[]
    paging?: TPagingNode
    meta?: TQueryMeta
}

Nodes are plain data with a kind discriminator rather than classes. That keeps the query serialisable — for a cache key, a persisted query, a snapshot test — and makes it survive two copies of the package in one dependency tree, where instanceof would quietly fail.

Operators are open strings: QueryOperators holds the canonical set, and a dialect is free to add its own (f.op('similarTo', 'email', 'john')).


Writing a dialect

A dialect is one class implementing IQueryCompiler<TRequest>. Here is the whole thing for a small REST backend.

1. Compile filters with the visitor

Extend FilterVisitor and handle the four node kinds. Anything the backend can't express throws — loudly.

The visitor takes an optional context type — FilterVisitor<TOut, TScope> — that visit passes down to every node, so a compiler that has to track where it is (a relation path, a variable table, an alias counter) can stay stateless and reentrant instead of being rebuilt per compilation.

export class RestFilterCompiler extends FilterVisitor<Record<string, unknown>> {
    constructor(private readonly context: CompileContext) {
        super()
    }

    protected visitComparison(node: TComparisonNode): Record<string, unknown> {
        const resolved = this.context.resolve(node.path)          // createdAt → created_at
        return {
            [`${resolved.sourcePath.join('.')}[${node.operator}]`]:
                this.context.serialize(node.value, resolved.attribute),
        }
    }

    protected visitLogical(node: TLogicalNode): Record<string, unknown> {
        if (node.operator !== 'and') {
            throw new UnsupportedOperationError(`RestDialect cannot compile "${node.operator}"`)
        }
        return Object.assign({}, ...this.visitAll(node.nodes))
    }

    protected visitRelation(node: TRelationFilterNode): Record<string, unknown> {
        throw new UnsupportedOperationError(`RestDialect cannot filter on "${node.path.join('.')}"`)
    }

    protected visitRaw(node: TRawFilterNode): Record<string, unknown> {
        return node.payload as Record<string, unknown>
    }
}

2. Declare what the dialect can do

QueryValidator checks the AST against the capabilities before anything is compiled, and reports every problem at once instead of failing on the first:

QueryValidationError: RestDialect cannot compile the query
  - logical "or" is not supported
  - relation filters are not supported (path: books)

| Capability | Meaning | | --- | --- | | operators | Supported operator list, or 'all'. | | logical | or, not, and whether conditions may nest. | | relationFilters | Whether relation conditions work, and which quantifiers. | | relationArguments | Whether an included relation may carry its own filter / order / paging. | | paging | Which of offset · page · cursor are understood. | | ordering | Multiple keys, null placement, ordering through a relation. | | selection | 'none' · 'flat' · 'tree'. | | raw | Whether raw conditions may pass through. |

Start from QueryCapabilityPresets.full(), .minimal() or .none() and adjust. .none() supports nothing at all — it is the starting point for a dialect that composes its capabilities from the strategies actually plugged into it, so swapping in a richer filter encoder widens what the validator accepts on its own:

const capabilities = QueryCapabilityPresets.full()
capabilities.logical = { or: false, not: false, nesting: false }
capabilities.paging = ['offset']
capabilities.selection = 'flat'

3. Put the compiler together

export class RestCompiler implements IQueryCompiler<TRestRequest> {
    public readonly name: string = 'RestDialect'

    public readonly capabilities: TQueryCapabilities = RestCompiler.defaults()

    public compile(ast: TQueryAst, context: CompileContext): TRestRequest {
        new QueryValidator(this.capabilities, this.name).validate(ast)
        return {
            params: ast.filter ? new RestFilterCompiler(context).visit(ast.filter) : {},
            fields: new SelectionResolver({ maxDepth: 1 })
                .resolve(ast.selection, context)
                .map(p => p.sourceName),
        }
    }
}

4. Expose it as an entity query

QueryableEntityQuery already carries the AST and the builder methods, and clones itself on every call — an entity set never leaks the state of an earlier query. A transport package only adds the execution methods:

export class RestEntityQuery<T extends RepoEntityBase>
    extends QueryableEntityQuery<T, HttpTransport, TRestOptions> {

    public async getAll(): Promise<T[]> {
        const response = await this.transport.send<Record<string, any>[]>(this.compile())
        return response.map(p => this.entityConstructor.build(p, { naming: this.naming() }) as T)
    }

    protected compile(): TRestRequest {
        return new RestCompiler().compile(this.ast, this.createContext())
    }

    protected naming(): NamingStrategy | undefined {
        return this.options ? this.options.naming : undefined
    }
}

Five protected hooks shape the base class to the dialect. All of them are optional:

| Hook | Default | What it changes | | --- | --- | --- | | naming() | none | The NamingStrategy handed to CompileContext. | | values() | new ValueSerializer() | A custom ValueSerializer for the dialect's scalars. | | meta() | {} | Dialect-level data on CompileContext.meta, next to the query-level withMeta. | | pagingKind() | 'offset' | Which paging node a bare take(n) produces, so application code stays portable. | | copyStateTo(instance) | nothing | Carries a subclass's own state across the clone every builder call makes. |

copyStateTo is the one to remember: the base class copies the AST and nothing else, so a query that adds its own state — headers, a request rewriter, per-query options — must hand it over, or it silently disappears on the next builder call.

export class RestEntityQuery<T extends RepoEntityBase> extends QueryableEntityQuery<T, HttpTransport, TRestOptions> {
    protected headers: TRestHeaders = {}

    public withHeaders(headers: TRestHeaders): this {
        const instance = this.clone(this.ast)
        instance.headers = { ...this.headers, ...headers }
        return instance
    }

    protected copyStateTo(instance: this): void {
        instance.headers = { ...this.headers }
    }

    protected pagingKind(): TPagingKind {
        return 'cursor'
    }
}

With pagingKind() set to 'cursor', the very same application code

context.accounts.where(f => f.eq('status', 'active')).take(20)

produces { kind: 'cursor', first: 20 } on a Relay backend and { kind: 'offset', limit: 20 } on a REST one. first(n) and last(n) are there for when the application really does mean a cursor window regardless of the dialect.

Helpers a dialect gets for free

CompileContext — everything needed to turn a property key into what the data source understands:

| Member | Description | | --- | --- | | entity | The entity class the query runs against. | | sourceName(key) | The field's name in the data source. | | attribute(key) / attributes() | Field metadata. | | primaryKey() | Property key of the primary key. | | resolve(path) | Walks a path through relations; returns the source path and the attributes. | | serialize(value, attribute?) | Turns a value into its wire form. | | forRelation(key) / forEntity(entity) | A context for the related entity. |

PathResolver rejects an unknown field with UnknownFieldError and a path that continues through a scalar with QueryBuildError — a typo can never reach the data source.

ValueSerializer converts dates to ISO strings and entities to their primary key. Subclass it and pass it through the context to support custom scalars.

SelectionResolver expands the selection against the entity metadata — defaults, explicit fields, exclusions, source names, nested relations. The default expansion stops at maxDepth and at an entity already present in the current branch, so a self-referencing model terminates. An explicitly included relation is always kept: explicit intent beats the default.


Escape hatches

No AST covers every data source, so every level has a way out:

| Level | Way out | | --- | --- | | Condition | f.raw(payload, dialect?) · f.op(operator, key, value) · f.opPath(operator, path, value) | | Query | withMeta(meta) — directives, hints, dialect-specific arguments | | Field | select(s => s.withFieldMeta(key, meta)) | | Request | Whatever the transport package exposes after compilation |

A raw condition can be pinned to one dialect (f.raw(payload, 'GraphQLDialect')) so other dialects can reject or ignore it instead of passing nonsense along.


Builder reference

Available on QueryBuilder and — except where noted — on any QueryableEntityQuery. On the builder each call returns a new QueryBuilder; on an entity query it returns a new entity query, ready to execute.

| Method | Description | | --- | --- | | where(build) | Replaces the filter. | | andWhere(build) / orWhere(build) | Merges a condition into the existing filter. | | rawFilter(payload, dialect?) | Merges a dialect-specific condition. | | orderBy(key, direction?, nulls?) | Appends an ordering. | | orderByPath(path, direction?, nulls?) | Appends an ordering through a relation. | | clearOrder() | Drops the ordering. (QueryBuilder only) | | take(limit) / skip(offset) | Offset paging — take follows the dialect's pagingKind(). | | page(number, size) | Page paging. | | after(cursor) / before(cursor) | Cursor paging. | | first(limit) / last(limit) | An explicit cursor window. | | select(build) | Builds the field selection. | | include(key, build?) | Selects a relation, optionally with its own query. | | exclude(...keys) | Drops fields from the selection. | | withMeta(meta) | Attaches dialect-specific data to the query. | | toAst() | Returns the AST. |

Errors

| Error | Raised when | | --- | --- | | QueryBuildError | The query is contradictory while being built (mixed paging kinds, an empty path, a relation step through a scalar). | | UnknownFieldError | A path names a field the entity does not declare. | | QueryValidationError | The dialect cannot express the query; carries every TQueryIssue at once. | | UnsupportedOperationError | A dialect hits a node it cannot compile. |


License

MIT — see LICENSE.