Repositories
A repository is the typed data-access object for one model. It turns a @model schema into find, create, updateById, deleteById, and friends, with the query shape validated at compile time.
In one example
The smallest real repository: bind a model and a datasource, extend DefaultCRUDRepository.
import { repository, DefaultCRUDRepository } from '@venizia/ignis';
import { User } from '../models/user.model';
import { PostgresDataSource } from '../datasources/postgres.datasource';
@repository({ model: User, dataSource: PostgresDataSource })
export class UserRepository extends DefaultCRUDRepository<typeof User.schema> {}That's it - UserRepository already has find, findOne, findById, create, createAll, updateById, updateAll, deleteById, deleteAll, count, and existsWith.
How it works
- Engine-neutral contract, relational implementation.
AbstractRepository(engine-neutral,@venizia/ignis-kernel) declares the CRUD contract - no SQL, no Drizzle. The relational tier implements it as a chain of classes, each layer adding one capability (see table below). - Datasource is auto-injected.
@repository({ model, dataSource })auto-injects the datasource at constructor param[0] and lazily resolves the entity class from its own metadata. A plainextends DefaultCRUDRepository<...> {}needs no constructor at all. - Two repository types.
typedefaults toRepositoryTypes.MODEL, which requiresmodel.RepositoryTypes.REMOTEtakes nomodel- see Read another service's API. - One options object per verb. Reads and updates carry a
filter(where,fields,include,order,limit,offset). Writes carrydata. Every verb also accepts anoptionsbag fortransactionandshouldSkipDefaultFilter;shouldReturnis a write-only option, andretry/shouldQueryRangeare read-only ones.
The relational class chain, and the Postgres binding of each rung
| Neutral class | Postgres binding | Adds |
|---|---|---|
RelationalBaseRepository | PostgresBaseRepository | FilterBuilder/UpdateBuilder, hidden-column exclusion |
ReadableRelationalRepository | ReadableRepository | The read verbs |
PersistableRelationalRepository | PersistableRepository | create/update/delete |
DefaultRelationalRepository | DefaultCRUDRepository | Empty - the recommended entry point |
SoftDeletableRelationalRepository | SoftDeletableRepository | Overrides delete to set deletedAt instead of removing the row |
A Postgres class is not an alias for the neutral one beside it, and it does not extend the Postgres class above it. Each one extends its own neutral counterpart and rebinds two generic defaults - ExtraOptions to IDatabaseExtraOptions and TDataSource to IPostgresDataSource. SQLite binds the same five rungs as SqliteBaseRepository, ReadableSqliteRepository, PersistableSqliteRepository, DefaultSqliteRepository, and SoftDeletableSqliteRepository.
Import the Postgres names from @venizia/ignis or @venizia/ignis/postgres, the SQLite names from @venizia/ignis/sqlite, and the neutral names from @venizia/ignis/relational. The neutral names are deliberately absent from the engine subpaths, so one class never ships under two names.
Common tasks
Read with a filter
find and findOne take filter.where, plus order and limit for paging.
const users = await userRepository.find({
filter: {
where: { status: 'active' },
order: ['createdAt DESC'],
limit: 20,
},
});See Filter System for every operator (gte, like, inq, JSON paths, and/or).
Create a record
create returns { count, data }, not the bare record - count is 1 on success, data is the inserted row (or null if options.shouldReturn: false).
const { count, data } = await userRepository.create({
data: { email: 'jane@example.com' },
});Update by id
updateById returns the same { count, data } shape, with data set to the updated row.
const { data: updated } = await userRepository.updateById({
id: '123',
data: { email: 'new@example.com' },
});Soft delete
Extend SoftDeletableRepository instead of DefaultCRUDRepository on a model with a deletedAt column - deleteById sets the timestamp instead of removing the row, and restoreById clears it.
@repository({ model: Category, dataSource: PostgresDataSource })
export class CategoryRepository extends SoftDeletableRepository<typeof Category.schema> {}
await categoryRepository.deleteById({ id: '123' }); // sets deletedAt
await categoryRepository.restoreById({ id: '123' }); // clears deletedAtSee SoftDeletableRepository for hard delete and bulk restore.
Include relations
Pass include in the filter to eager-load related rows, with an optional nested scope filter.
await userRepository.find({
filter: {
include: [{ relation: 'posts', scope: { where: { published: true } } }],
},
});See Relations & Includes for one-to-many, many-to-many, and nested includes.
Run inside a transaction
beginTransaction() delegates to the datasource; pass the handle as options.transaction on any repository call to run it inside that transaction.
const transaction = await userRepository.beginTransaction();
try {
await userRepository.create({ data: { email: 'a@example.com' }, options: { transaction } });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}See DataSources for the rollback-safe pattern (rollback() itself can throw). See Advanced Features for isolation levels and other transaction options.
runInTransaction({ transaction?, transactionOptions?, execute }) writes the try/catch above for you - see Transactions - runInTransaction.
Retry a read behind a replicated pool
A read right after a write can hit a replica that has not caught up. Pass retry to re-read until the result is fresh:
const user = await userRepository.findById({
id,
options: { retry: { maxAttempts: 4 } },
});Full options and rules: Advanced Features - Read Retry.
Read and write another service's API
Data behind another service has no local model, so @repository would have nothing to put in model. Declare the repository RepositoryTypes.REMOTE and give it the datasource only:
import { HttpDataSource, HttpRepository } from '@venizia/ignis-connectors/http';
import { datasource, repository, RepositoryTypes } from '@venizia/ignis';
type TProduct = { id: string; name: string };
@datasource()
export class CatalogDataSource extends HttpDataSource {
constructor() {
super({ name: 'catalog', baseUrl: 'https://catalog.internal/api' });
}
}
@repository({ type: RepositoryTypes.REMOTE, dataSource: CatalogDataSource })
export class ProductRepository extends HttpRepository<TProduct> {
constructor(dataSource: CatalogDataSource) {
super({ dataSource, resource: 'products' });
}
}The datasource is still injected at constructor param[0] - by its class, so a datasource pinned to its own key (@datasource({ binding })) is found even after a minified build renames it. No model binding is registered, so the datasource discovers no schema through this repository.
type | Value | model | Registers |
|---|---|---|---|
RepositoryTypes.MODEL (default) | 'model' | Required | Model binding + datasource injection |
RepositoryTypes.REMOTE | 'remote' | Refused | Datasource injection only |
TypeScript rejects a MODEL repository without model and a REMOTE one with it. A JavaScript caller gets the same answer at decoration time, as does an unknown type.
findOne asks the list route for one row. Pass findOnePath: 'find-one' to the constructor to call GET /products/find-one instead, the route defineCrudController generates; it answers a missing row with HTTP 200 and no row, so findOne returns null. countPath does the same for the count route.
The same repository writes through the IGNIS CRUD routes the other service generates:
| Method | Request |
|---|---|
create({ data }) | POST /products |
updateById({ id, data }) | PATCH /products/:id |
updateAll({ where, data }) / updateBy | PATCH /products, where in the body beside the data |
deleteById({ id }) | DELETE /products/:id |
deleteAll({ where }) / deleteBy | DELETE /products, where in the body |
const { data: product } = await productRepository.create({ data: { name: 'Lamp' } });
await productRepository.updateById({ id: product.id, data: { name: 'Desk lamp' } });
await productRepository.deleteAll({ where: { id: { inq: staleIds } } });- A write sends a partial row by default (
HttpRepository<TRow, TWrite = Partial<TRow>>), since the server fills ids and defaults. - The id is URL-encoded. A bulk
wheretravels in the body, so a long id list does not hit the URL limit. - A read filter travels in the GET query string, so a long
inqdoes hit it: about 170 UUIDs fit behind an 8 KB proxy buffer and about 400 behind 16 KB, after which the server or proxy answers 414 or 431, and the error says the URL is too long. The generated routes take no body filter. Split the id list into reads that fit (a split read has no singletotal), or call aPOSTroute the other service wrote by hand throughdataSource.read({ paths, method: 'POST', body: { filter } }). - An empty bulk
whereis refused before any request, as the server would refuse it;forcecannot cross HTTP. shouldReturn: falseanswers the count withdata: null. The count is the server'sx-response-count.- A failed write - or read - throws the
ApplicationErrorthe server threw: its status,normalizedcode, args and text, and itsextrawith therequestIdadded. The message names the verb, status and path - never the host or the query. A 422's per-field issues ({ path, message, code }) are the error'scause. - An empty,
.or..id is refused before any request: a URL resolves those to the list or the parent route. - There is no
createAll: the IGNIS REST contract has no bulk-create route.
For a route the verbs do not cover - a PUT, an upload, an action endpoint - call the datasource:
// A plain object or array is sent as JSON; FormData, Blob, a string or binary goes as it is.
await catalogDataSource.write({ paths: ['products', id], method: 'PUT', body: replacement });
const response = await catalogDataSource.request({
paths: ['products', 'import'],
method: 'POST',
body: formData,
headers: { 'idempotency-key': key },
});write answers { data, count } and throws on a non-2xx; request answers the raw Response. Both carry the auth token, and the 401 retry resends the same body.
Settings that follow the session
A browser app's headers and token change while it runs. Give the datasource hooks, not values. headersResolver and authTokenResolver run on every send, the 401 retry included; onUnauthorized runs on a 401. All three are told which request they serve ({ paths, method, url, signal }):
new HttpDataSource({
baseUrl: '/api',
errorRootKey: 'error',
headersResolver: () => ({ 'x-locale': session.locale }),
authTokenResolver: ({ paths }) => (paths[0] === 'auth' ? undefined : session.token),
onUnauthorized: ({ paths }) => (paths[0] === 'auth' ? false : session.refresh()),
});| Setting | Meaning |
|---|---|
baseUrl | An absolute URL, or a path from the page origin (/api). A path-relative one (api) is refused: it would follow the current route. |
errorRootKey | The server's error.rootKey. Without it, a wrapped error envelope reads as core.system_error. |
headers | Sent on every request. |
headersResolver | Headers for this request. Wins over headers. |
authToken / authTokenResolver | The token. A fixed authToken wins and goes on every request; the resolver answering undefined sends none. |
onUnauthorized | Runs on a 401; true retries once, false lets it stand. A call whose signal aborted meanwhile is not retried. |
One call adds its own headers and an abort signal through options, on every verb:
const controller = new AbortController();
await productRepository.find({ filter, options: { headers: { 'x-trace': id }, signal: controller.signal } });Header precedence, weakest first: headers, headersResolver, the call's own, then the connector's (x-request-count, authorization). A FormData body always carries the multipart content-type fetch writes, boundary included.
WARNING
Cross-origin, count() and find({ options: { shouldQueryRange: true } }) read Content-Range, which a browser hides unless the server lists it in Access-Control-Expose-Headers (with x-response-count for writes).
A list route that offers extras (see Extras beside the rows) answers the ones the call names - true for a plain one, a list of keys for a group, false to switch a default off:
const { data, range, extra } = await productRepository.find({
filter,
options: { shouldQueryRange: true, extra: { facets: ['status', 'category'], counts: false } },
});
extra.facets.status; // keys typed from what was asked forWithout shouldQueryRange, find with extra answers { data, extra }; asked for, extra is always present, and each entry is optional. read({ extra }) and write({ extra }) take the same request. The connector unwraps { data, extra } only when the response carries x-response-extra. count, existsWith and findOne ask for the rows alone (-*), so no default is computed for them. A client without the connector builds the header with HttpExtraRequest.toHeader and reads the body with HttpResponseReader.readExtra, both from @venizia/ignis-kernel/repository.
The connector's readers are exported for a client that talks to the same server - from @venizia/ignis-kernel/repository, so that client needs no connector, and re-exported from /http: HttpResponseReader.parseContentRange({ header }), HttpResponseReader.readErrorEnvelope({ body, rootKey }), HttpResponseReader.readError({ response, rootKey }) and HttpResponseReader.toApplicationError({ response, context, rootKey }). The last one is the connector's own error mapping: the status, the normalized code, args and text (HTTP <status> when the server sent none), extra with requestId and the relayed upstreamRequestIds, and details.cause as cause. context is your log prefix, for example [Client][read] 404 | /tickets.
See also
- Relations & Includes - eager loading, nested
scopefilters, many-to-many - Advanced Features - transactions, hidden properties,
shouldQueryRange, performance, read retry - SoftDeletableRepository - soft delete, restore, hard delete
- Repository Mixins (Removed) - where
FieldsVisibilityMixin/DefaultFilterMixinbehavior lives now - Filter System - every
whereoperator, ordering, pagination - Repositories Guide - creating repositories step by step
Files:
packages/kernel/src/base/repositories/core/abstract.ts- neutralAbstractRepositorypackages/connectors/src/relational/core/repositories/core/- the engine-neutral relational ladder, where every verb is implementedpackages/connectors/src/relational/postgres/repositories/core/- the Postgres bindings, one subclass per neutral class