packages/wrap/src/base.repository.ts. See also: Offline-first sync guide.
BaseRepository<Tb, TCreate?, TUpdate?> (abstract)
abstract class BaseRepository<
Tb extends BaseTable,
TCreate extends PgInsertValue<Tb> = InferCreate<Tb>,
TUpdate extends PgUpdateSetSource<Tb> = InferUpdate<Tb>,
> {
protected abstract table: Tb;
protected logger: Logger;
protected get db(): WrapDatabase; // lazy — resolves ambient transaction, then getDatabase()
// Populate support (optional):
protected relationalQuery?: () => RelationalQueryLike;
protected defaultWith?: NonNullable<WithConfig<Tb>>;
// findPaginated's default search columns (optional):
protected searchableFields?: Array<keyof Tb["_"]["columns"] & string>;
// Entity-scope caching (optional):
protected cacheTtl?: number;
}
The Drizzle table is the single source of truth — entity, create, and update shapes are all inferred from it (InferEntity/InferCreate/InferUpdate, types/base.ts). Tb must include BaseRow’s columns (id, createdAt, updatedAt, deletedAt) — every table built via Entity(...) (see Entity) already does.
@Repository("FooRepository")
class FooRepository extends BaseRepository<typeof Foo.table, CreateFooDTO, Partial<CreateFooDTO>> {
protected table = Foo.table;
protected override relationalQuery = () => this.db.query.FooTable; // enables `with`
protected override defaultWith = { items: true } as const; // populateChildren=true default
protected override searchableFields = ["name", "description"] as const;
protected override cacheTtl = 30; // seconds — opt into entity-scope caching
}
CRUD methods
| Method | Signature | Notes |
|---|---|---|
create |
create(dto: TCreate): Promise<InferEntity<Tb>> |
Emits an "created" entity event. |
findById |
findById<W>(id, options?: FindOptions<Tb, W>): Promise<FindResult<Tb, W> \| null> |
Excludes soft-deleted rows unless options.includeDeleted. Populates via options.with when given. Cached if cacheTtl is set. |
findPaginated |
findPaginated<W>(query: PaginationQuery, options?): Promise<PaginatedResponse<FindResult<Tb, W>>> |
See “Pagination query” below. |
findAll |
findAll<W>(filters?, options?): Promise<FindResult<Tb, W>[]> |
filters becomes AND-ed equality conditions; array values become IN. |
findOne |
findOne<W>(filters?, options?): Promise<FindResult<Tb, W> \| null> |
— |
update |
update(id, dto: TUpdate): Promise<InferEntity<Tb>[] \| null> |
null if no row matched. Emits "updated". |
delete |
delete(id): Promise<boolean> |
Hard delete (DELETE FROM). Emits "deleted" if a row was actually removed. For a sync-friendly soft delete, use applyBatch([{ op: "delete", ... }]) instead — see sync. |
deleteMultiple |
deleteMultiple(ids): Promise<number> |
Returns the count actually deleted; emits one "deleted" event with all ids. |
findBy |
findBy<K>(field: K, value): Promise<InferEntity<Tb>[]> |
— |
findOneBy |
findOneBy<K>(field: K, value): Promise<InferEntity<Tb> \| null> |
— |
count |
count(filters?): Promise<number> |
— |
exists |
exists(id): Promise<boolean> |
findById(id) !== null. |
getStatistics |
getStatistics(): Promise<EntityStatistics> |
Monthly/weekly/yearly counts vs. the prior period, based on createdAt. |
interface FindOptions<Tb, W> {
with?: W; // typed against the table's relations() config
includeDeleted?: boolean;
}
Pagination query
interface PaginationQuery {
page?: number; // default 1
pageSize?: number; // default 10
search?: string; // matched against searchableFields with ILIKE, space-split terms OR'd
sortBy?: string; // default "id"; falls back to "id" if the column doesn't exist
sortOrder?: SortOrder; // "asc" | "desc", default "asc"
includeDeleted?: boolean;
populateChildren?: boolean; // uses defaultWith if no explicit `with` is given
filters?: Record<string, string | number | boolean | string[] | undefined>;
}
interface PaginatedResponse<T> {
items: T[]; itemCount: number; page: number; pageSize: number; pageCount: number;
}
Build one from raw query-string params with buildQuery() — see Helpers.
Offline-first sync
Full explanation: Sync guide.
async findChangedSince(
cursor: Date | string,
options?: { limit?: number }, // default 200
): Promise<SyncPage<InferEntity<Tb>>>
async applyBatch(
changes: Array<SyncChange<TCreate, TUpdate>>,
): Promise<SyncBatchResult>
interface SyncChange<TCreate, TUpdate> {
op: "create" | "update" | "delete";
id?: string | number; // required for update/delete
data?: TCreate | TUpdate;
updatedAt: string | Date; // drives last-write-wins conflict resolution
}
interface SyncBatchResult {
applied: Array<string | number>;
conflicts: Array<{ id: string | number; serverUpdatedAt: string }>;
}
interface SyncPage<T> {
items: T[];
nextCursor: string | null;
}
applyBatch runs the whole batch inside withTransaction() — atomic; throws immediately on an invalid updatedAt/cursor.
Caching
Set protected override cacheTtl = <seconds> to opt a repository into entity-scope read caching (findById/findAll/findOne/findPaginated). Any write on the entity invalidates the whole cache scope automatically via entity events — see Helpers — cache. Reads inside an active withTransaction() bypass the cache.
Populate (with)
Requires two things on the repository: protected relationalQuery = () => this.db.query.<TableName>; (a thunk — resolved at call time, never captured at construction, since the database might not be initialized yet when the repository class is first loaded) and the table’s relations() declared via relationsOf() in the entity module (see Entity). Calling a populate-requiring read without relationalQuery set throws a descriptive error.
Internals (protected, available to override)
| Member | Purpose |
|---|---|
column(name) |
Look up a PgColumn by name on the table, or undefined. |
db (getter) |
currentTransaction() ?? getDatabase() — the ambient transaction if one is active, else the app’s default connection. |
Type helpers
type RelationalQueryLike // structural shape of `db.query.<Table>` (findMany/findFirst)
type RepositoryTable<R> // R's Tb
Statistics types: StatisticsPeriod, StatisticsComparison, EntityStatistics (all exported from this module).