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

v3.2.1

Published

A TypeScript library that abstracts data sources and provides a unified context-based access to entities, queries, and transports.

Readme

@iappx/entity-repo

npm CI license node

Your application talks to entities. Where they come from is somebody else's problem.

The core of the entity-repo family: entities that know their own metadata and their own changes, contexts that gather them into one access point, and transports that stay behind an interface. It never opens a socket and never assumes a wire format — that is what the dialect packages are for.

npm install @iappx/entity-repo

How it works

      your code                  the core                   a dialect package             the wire
 ┌───────────────────┐   ┌──────────────────────┐   ┌──────────────────────┐   ┌────────────────┐
 │ context.accounts  │   │ EntityContextBase    │   │ RestEntityQuery      │   │ GET /accounts  │
 │   .where(…)       │ → │ RepoEntityBase       │ → │ GqlEntityQuery       │ → │ query { … }    │
 │   .getAll()       │   │ EntityQuery          │   │ your own subclass    │   │ sockets · sql  │
 └───────────────────┘   └──────────────────────┘   └──────────────────────┘   └────────────────┘
   one access point         entities, metadata,        ITransport  ·  IStreamTransport
   for every source         change tracking            nothing in this package sends anything

Business logic depends on the context. Swapping the data source is swapping the transport handed to EntityRepo.use.

The packages

The third box is a separate package. Two of them are already written, so a REST or a GraphQL backend needs no query and no transport code of your own:

| Package | What it is | | --- | --- | | @iappx/entity-repo (this one) | The core. Entities, metadata, change tracking, contexts, transport interfaces. Everything below builds on it. | | @iappx/entity-repo-query | A typed builder — where · orderBy · take · select · include — that compiles to a plain-JSON query AST, plus QueryableEntityQuery and the capability validator. | | @iappx/entity-repo-rest | The REST dialect: the AST becomes an HTTP request. Filters, ordering, paging, urls, methods and response shapes are swappable; FetchTransport is 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 entirely on its own. |

Highlights

| | | | --- | --- | | 🧱 Entities describe themselves | getAttributes(), getRelations(), getSourceNames() are static — a query builds a request without ever instantiating the entity. | | ✏️ Change tracking that survives nesting | An edit three levels down marks the entity dirty; serialize({ onlyChanged: true }) turns that into a patch instead of a full body. | | 🗺️ Names are a strategy | createdAt → created_at through NamingStrategies, or per field through name. Reading accepts either spelling. | | 🚪 One access point | Entity sets hang off a context. Nothing in the application layer imports a repository, a client or a url. | | 🔌 Transports are two methods | send for request/response, subscribe for streams. Axios, fetch, a websocket or an in-memory fake are the same size. | | 🧩 Extensible without a fork | Transport packages widen EntityAttribute through declaration merging — gqlType is added by the GraphQL dialect, not by this package. | | 🧊 A fresh query per access | context.accounts builds a new query object every time, so state from an earlier call can never leak into the next one. | | 🪶 Zero runtime dependencies | Nothing is imported. It runs in Node, in the browser and in workers alike. |

Requirements

  • Node.js >= 18
  • TypeScript with experimentalDecorators

Entity Repo is built on legacy (experimental) decorators, which are not the default in TypeScript 5:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

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 Book extends RepoEntityBase<Book> {
    @RepoEntityField({ isPrimaryKey: true })
    public id!: number

    @RepoEntityField()
    public name!: string
}

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

    @RepoEntityField()
    public email!: string

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

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

2. Write a transport

A transport is the only thing that touches the network. For request/response it implements ITransport:

import { ITransport } from '@iappx/entity-repo'
import { Axios, AxiosRequestConfig } from 'axios'

export class AxiosTransport implements ITransport<AxiosRequestConfig> {
    protected client: Axios

    constructor(config?: AxiosRequestConfig) {
        this.client = new Axios(config)
    }

    public async send<TRes>(config: AxiosRequestConfig): Promise<TRes> {
        const response = await this.client.request(config)
        return response.data as TRes
    }
}

3. Write an entity query

EntityQuery is where access logic lives. It receives the entity class, the transport and the options declared on the entity set:

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

export type TRestOptions = {
    endpoint: string
}

export class RestEntityQuery<T extends RepoEntityBase> extends EntityQuery<T, AxiosTransport, TRestOptions> {
    public async getAll(): Promise<T[]> {
        const response = await this.transport.send<Record<string, any>[]>({ url: this.options.endpoint })
        return response.map(p => this.entityConstructor.build(p))
    }

    public async create(entity: T): Promise<T> {
        const response = await this.transport.send<Record<string, any>>({
            url: this.options.endpoint,
            method: 'POST',
            data: entity.serialize({ operation: 'create' }),
        })
        return this.entityConstructor.build(response)
    }
}

(Both @iappx/entity-repo-rest and @iappx/entity-repo-gql ship a query like this — with filters, paging, selection and writes already implemented.)

4. Gather the entity sets into a context

import { EntityContextBase, RepoEntitySet } from '@iappx/entity-repo'

export class AppContext extends EntityContextBase<AxiosTransport> {
    @RepoEntitySet(() => Account, () => RestEntityQuery, { endpoint: '/api/accounts' })
    public accounts!: RestEntityQuery<Account>

    @RepoEntitySet(() => Book, () => RestEntityQuery, { endpoint: '/api/books' })
    public books!: RestEntityQuery<Book>
}

5. Use it

import { EntityRepo } from '@iappx/entity-repo'

const context = EntityRepo.create()
    .use(AppContext, new AxiosTransport({ baseURL: 'https://api.your.app' }))
    .getContext(AppContext)

const accounts = await context.accounts.getAll()

const account = Account.build({ email: '[email protected]' })
await context.accounts.create(account)

The application never learns that there is HTTP behind context.accounts. Point the same context at a different transport in a test and the assertions do not move.


Entities

The instance API

| Member | Description | | --- | --- | | Entity.build(data, options?) | Creates an instance from raw data, turning nestedType fields into entities. The result is clean — the data is assumed to come from the source. | | getDataValue(key) | Reads a single field. | | setDataValue(key, value, options?) | Writes a single field. A key the entity does not declare is ignored. | | getDataValues() | Serialises into a plain object, keyed by property name, nested entities included. | | setDataValues(data, options?) | Applies a raw object — or another entity — to this one. | | serialize(options?) | Builds the payload for the data source: source names, write rules, changed fields only if asked. | | getPkValue() | The value of the isPrimaryKey field. Throws if the entity declares none. | | copy() | A deep copy: nested entities and arrays are recreated, not shared. | | isChanged(key?) | Whether the field — or, without a key, the entity or anything nested in it — has changes. | | getChangedKeys() | Property keys that changed since the last clearChanges(). | | getChangedValues() | The changed fields as a plain object. | | markChanged(key, changed?) | Marks a field as changed, or clears the mark. | | clearChanges() | Accepts the current state as the state of the data source, nested entities included. |

const account = Account.build({
    id: '1',
    email: '[email protected]',
    books: [{ id: 1, name: 'Dune' }],
})

const draft = account.copy()
draft.books[0].name = 'Neuromancer'

account.books[0].name   // 'Dune' — the copy is deep
account.getPkValue()    // '1'
account.isChanged()     // false — build() does not mark anything

Field options

@RepoEntityField accepts the following:

| Option | Description | | --- | --- | | isPrimaryKey | Marks the field as the primary key of the entity. | | nestedType | Lazy resolver of the entity type the field holds — lazy so that circular imports resolve. | | isArray | Declares that a nestedType field holds a collection. | | name | Name of the field in the data source. Wins over any naming strategy. | | isReadonly | Never sent, on any write operation. | | isGenerated | Produced by the data source, so the field is not sent on create. | | isClientOnly | Exists on the client only: neither sent nor requested. |

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

    @RepoEntityField({ name: 'e_mail' })
    public email!: string

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

    @RepoEntityField({ isClientOnly: true })
    public isSelected!: boolean
}

Metadata

The metadata lives on the class, so a query can build a request without instantiating anything:

| Member | Description | | --- | --- | | Entity.getAttributes() | Every declared field, own and inherited. | | Entity.getAttribute(key) | A single field, or undefined. | | Entity.getPrimaryKey() | Property key of the primary key, or undefined. | | Entity.getRelations() | Fields that hold another entity, with nestedType resolved. | | Entity.getSourceName(key, naming?) | The name the data source uses for a field. | | Entity.getSourceNames(naming?) | Property key → source name, without the client-only fields. |

Account.getPrimaryKey()   // 'id'
Account.getSourceNames()  // { id: 'id', email: 'e_mail', createdAt: 'createdAt' }
Account.getRelations()    // [{ key: 'books', type: Book, isArray: true, attribute: {…} }]

This is the whole reason the dialect packages can resolve f.eq('status', 'active') into a wire-level condition without a schema: the entity already said everything they need.

Naming

A field maps to the data source through its explicit name, or through the naming strategy the transport passes. NamingStrategies ships snakeCase, kebabCase and pascalCase; any (propertyKey, attribute) => string will do.

const account = Account.build(response, { naming: NamingStrategies.snakeCase })
account.serialize({ naming: NamingStrategies.snakeCase, operation: 'create' })

Reading accepts both the source name and the property key, so a response does not have to be normalised first. getDataValues() always returns property keys — source names appear in serialize() only.

Writes and change tracking

build, and setDataValues(data, { markChanged: false }), state that the data comes from the source, so the entity stays clean. Everything assigned afterwards is tracked, nested entities included:

const account = await context.accounts.getOne('1')
account.email = '[email protected]'

account.isChanged()       // true
account.getChangedKeys()  // ['email']

account.serialize({ operation: 'update', onlyChanged: true })
// { e_mail: '[email protected]' }

account.clearChanges()

serialize takes:

| Option | Effect | | --- | --- | | naming | The naming strategy for the source names. | | operation | 'create' or 'update'. Given either, isReadonly fields are dropped; on 'create', isGenerated too. | | onlyChanged | Only fields that changed, or that contain something changed. | | includeClientOnly | Keeps isClientOnly fields in the payload. Off by default. |

A field that changed as a whole is serialised as a whole, and so is a collection in which any item changed: a partially serialised item cannot be matched back by the data source.


Transports

A transport is the only component that knows how to reach the data source. Two interfaces, and an implementation may provide either or both:

interface ITransport<TSendParams> {
    send<TRes>(params: TSendParams): Promise<TRes>
}

interface IStreamTransport<TSubscribeParams> {
    subscribe<TRes>(params: TSubscribeParams): AsyncIterable<TRes>
}

IStreamTransport is for a source that delivers a stream of values rather than one answer — GraphQL subscriptions, SSE, websockets:

export class WsTransport implements IStreamTransport<SubscribeParams> {
    public subscribe<TRes>(params: SubscribeParams): AsyncIterable<TRes> {
        return this.client.subscribe(params)
    }
}

A context accepts either kind (AnyTransport). TransportGuards.isSendTransport and TransportGuards.isStreamTransport tell them apart at runtime, which is how a query can offer getAll() and watch() side by side and fail clearly when the transport it was handed cannot do one of them:

export class AccountQuery extends EntityQuery<Account, WsTransport, TQueryOptions> {
    public async* watch(): AsyncIterableIterator<Account> {
        for await (const message of this.transport.subscribe<Record<string, any>>({ topic: this.options.topic })) {
            yield this.entityConstructor.build(message)
        }
    }
}

Entity queries

EntityQuery<TEntity, TTransport, TOptions> is deliberately almost empty — it holds three things and lets the subclass decide what a query means:

| Member | Description | | --- | --- | | entityConstructor | The entity class, with all of its static metadata. public. | | transport | The transport the context was created with. protected. | | options | The options declared on the entity set. protected. |

Everything else — getAll, create, filters, paging — is yours to define, or comes from a dialect package. See QueryableEntityQuery if you want the builder methods for free.

Contexts

@RepoEntitySet(entity, entityQuery, options?) declares an entity set on a context. Both the entity and the query are passed as thunks, so circular imports between a context and its entities resolve.

export class AppContext extends EntityContextBase<AxiosTransport> {
    @RepoEntitySet(() => Account, () => RestEntityQuery, { endpoint: '/api/accounts' })
    public accounts!: RestEntityQuery<Account>
}

Reading context.accounts constructs a new query instance every time, from the entity class, the context's transport and the options above. Nothing an earlier call did can survive into the next one.

EntityRepo binds a context class to a transport instance:

const repo = EntityRepo.create()
    .use(AppContext, new AxiosTransport({ baseURL: 'https://api.your.app' }))
    .use(ReportingContext, new ReadOnlyTransport())

const context = repo.getContext(AppContext)   // throws if the context was never registered

Several contexts can live in one repo, each with its own transport — a read model against GraphQL and a write model against REST is two use calls, not two architectures.


Extending the core

Transport packages widen the field options through declaration merging, so a dialect adds what it needs without a change here:

declare module '@iappx/entity-repo' {
    interface EntityAttribute {
        gqlType?: string
    }
}
@RepoEntityField({ isPrimaryKey: true, gqlType: 'uuid!' })
public id!: string

That is exactly how @iappx/entity-repo-gql adds gqlType and gqlName: import the package once, anywhere, and the options appear on @RepoEntityField.


Where to go next

| You want | Package | | --- | --- | | A typed, backend-agnostic query builder and a serialisable AST | @iappx/entity-repo-query | | To talk to a REST API without writing a query-string encoder | @iappx/entity-repo-rest | | To talk to GraphQL without a schema, codegen or the graphql package | @iappx/entity-repo-gql | | To build GraphQL documents at runtime, on their own | @iappx/gql-builder | | To reach something else entirely | Write an ITransport and an EntityQuery — that is what this page is for |


License

MIT — see LICENSE.