@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.
Maintainers
Readme
@iappx/entity-repo
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-repoHow 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 anythingBusiness 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 anythingField 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 registeredSeveral 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!: stringThat 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.
