Skip to content
Warlock.js v4

Repositories

A repository in Warlock is a small class that sits between your services and your model. It owns filtering, pagination, and caching — the dull mechanics that every CRUD list endpoint needs and that should not have to be reinvented per call site.

The reason repositories exist as a primitive: every data layer in every app does the same five things — list with filters, find by id, create, update, delete. If you write all five in your service file, you end up with one filter system per service. If you write them in your model, you tangle output shape with data access. The repository is the shared home that gives every module the same surface and the same caching story.

This page covers the shape, the filter system, the cached variants, and how to call it from a service. Cursor pagination, custom adapters (Prisma, raw SQL), and the cache-invalidation story live in Repositories — deep dive.

src/app/products/repositories/products.repository.ts
import type { FilterRules, RepositoryOptions } from "@warlock.js/core";
import { RepositoryManager } from "@warlock.js/core";
import { Product } from "../models/product";
type ProductListFilter = {
ids?: string[];
category_id?: string;
status?: string;
search?: string;
};
export type ProductListOptions = RepositoryOptions & ProductListFilter;
class ProductsRepository extends RepositoryManager<Product, ProductListOptions> {
public source = Product;
public simpleSelectColumns: string[] = ["id", "name", "price"];
public filterBy: FilterRules = {
id: "=",
ids: ["in", "id"],
category_id: "=",
status: "=",
search: ["like", ["name", "description"]],
};
public defaultOptions: RepositoryOptions = {
orderBy: { id: "desc" },
};
}
export const productsRepository = new ProductsRepository();

Four properties on the class:

  • source — the model class. The framework infers the adapter from the source type.
  • simpleSelectColumns — the column subset used when callers pass simpleSelect: true (lightweight lists).
  • filterBy — the filter rules; maps incoming filter keys to query operations.
  • defaultOptions — applied to every query unless the caller overrides.

The bottom line exports a singleton — every service in the module imports the same instance.

One file per repository under the module’s repositories/ folder, named <entity>s.repository.ts (plural):

src/app/products/repositories/
products.repository.ts

The exported instance is camelCase + Repository suffix: productsRepository, faqsRepository, usersRepository.

These are the methods you’ll reach for daily. Every repository inherits them from RepositoryManager:

MethodWhat it returnsUse when
list(options?){ data, pagination }paginated read
listCached(options?){ data, pagination } (cached)list endpoints with stable filters
all(options?)T[]non-paginated read (be careful)
find(id)T | nullby primary key
findBy(column, value)T | nullby any column
first(options?)T | nullfirst match for options
getCached(id)T | null (cached)by primary key, cached
create(data)Tinsert
update(id, data)Tupdate by id
delete(id)voiddelete by id
count(options?)numbertotal matching records
exists(filter?)booleanexistence check
findOrCreate(where, data)Tupsert-by-where (insert if missing)
updateOrCreate(where, data)Ttrue upsert

There’s also chunk(size, callback) for processing large datasets without loading everything into memory, and listActive/findActive/... variants that auto-add an isActive filter — see Repositories — deep dive.

list(...) always returns the same envelope:

{
data: T[],
pagination: {
limit: number,
result: number, // count in current page
page: number,
total: number, // total across all pages
pages: number, // total page count
}
}

The controller passes this straight through:

const { data: products, pagination } = await productsRepository.list({
page: 2,
limit: 20,
category_id: "shoes",
});
return response.success({ products, pagination });

Twenty rows + the pagination metadata in one round-trip. The framework knows what page and limit mean; you don’t write the LIMIT/OFFSET SQL.

filterBy is the heart of the repository. Each rule maps a key in the caller’s options to a query operation:

public filterBy: FilterRules = {
id: "=", // exact match
ids: ["in", "id"], // WHERE id IN (...)
category_id: "=",
status: "=",
search: ["like", ["name", "description"]], // LIKE across two columns
};

Three forms:

FormBehaviour
"=" / ">" / "!="direct comparison on the same-named column
["op", "column"]comparison on a different column (rename incoming → DB column)
["op", ["col1", "col2"]]apply the comparison across multiple columns (OR’d)

The full operator set includes =, !=, >, >=, <, <=, like, not like, in, not in, between, plus type-coercing operators (int, bool, date, dateTime, dateBetween, inDate, …) and relation operators (with, joinWith, scope).

You can also pass a function for fully custom logic:

public filterBy: FilterRules = {
near: (value, query) => {
query.whereRaw("ST_Distance(location, ?) < ?", [value.point, value.radius]);
},
};

The full operator reference is in Repositories — deep dive.

defaultOptions is what the framework applies if the caller didn’t override:

public defaultOptions: RepositoryOptions = {
orderBy: { id: "desc" },
defaultLimit: 25,
};

Caller options always win — pass { orderBy: ["name", "asc"] } and it overrides the default. The default is what you want 90% of the time so the boring cases stay one-line.

listCached(...) and getCached(...) are the cached siblings. Same signature, same return shape — but they check the cache first, populate on miss, and serve subsequent reads from memory until the cache is invalidated:

const { data, pagination } = await productsRepository.listCached({
category_id: "shoes",
page: 1,
limit: 20,
});

The cache key includes the options, so different filter combinations get different cache entries. The framework also wires automatic invalidation: when the model emits created, updated, or deleted, the repository’s cache is cleared.

For reads with high traffic and low write churn (product catalog, taxonomy, lookup tables), listCached is a one-character win over list.

create, update, delete proxy to the underlying model (via the Cascade adapter):

const product = await productsRepository.create({
name: "T-shirt",
price: 29.99,
category_id: "apparel",
});
await productsRepository.update(product.id, { price: 24.99 });
await productsRepository.delete(product.id);

These also fire the model’s lifecycle events (creatingcreated, updatingupdated, deletingdeleted), which is how the cache invalidation hook above stays in sync. See Events and hooks in the Cascade docs for the full event surface.

You can also create() directly on the model — Product.create(...) — when you’re inside a service and don’t need the repository’s filter machinery. Both paths fire the same events; pick whichever reads cleaner.

The actual faqs repository from the reference codebase — thirty-five lines, complete:

src/app/faqs/repositories/faqs.repository.ts
import type { FilterRules, RepositoryOptions } from "@warlock.js/core";
import { RepositoryManager } from "@warlock.js/core";
import { Faq } from "../models/faq";
type FaqListFilter = {
ids?: string[];
id?: string;
organization_id?: string;
project_id?: string;
status?: string;
};
export type FaqListOptions = RepositoryOptions & FaqListFilter;
class FaqsRepository extends RepositoryManager<Faq, FaqListOptions> {
public source = Faq;
public simpleSelectColumns: string[] = ["id"];
public filterBy: FilterRules = {
id: "=",
ids: ["in", "id"],
organization_id: "=",
project_id: "=",
status: "=",
};
public defaultOptions: RepositoryOptions = {
orderBy: {
id: "desc",
},
};
}
export const faqsRepository = new FaqsRepository();

The service that calls it is one line:

src/app/faqs/services/list-faqs.service.ts
import { faqsRepository, type FaqListOptions } from "../repositories/faqs.repository";
export async function listFaqsService(filters: FaqListOptions) {
return faqsRepository.listCached(filters);
}

And the controller’s two lines:

src/app/faqs/controllers/list-faqs.controller.ts
import { type RequestHandler } from "@warlock.js/core";
import { listFaqsService } from "../services/list-faqs.service";
export const listFaqsController: RequestHandler = async (request, response) => {
const { data: faqs, pagination } = await listFaqsService({
...request.all(),
organization_id: request.user.organizationId,
});
return response.success({ faqs, pagination });
};

Three files, ~50 lines, full CRUD list with filtering, pagination, and caching. The repository carries all the mechanics; the controller is thin; the service is a one-line pass-through that exists so the controller doesn’t directly import the repository.

A common pattern: fetch by id, throw if missing, let the framework’s catch-all map to a 404.

src/app/faqs/services/get-faq.service.ts
import { ResourceNotFoundError } from "@warlock.js/core";
import { faqsRepository } from "../repositories/faqs.repository";
export async function getFaqService(id: number | string) {
const faq = await faqsRepository.getCached(id);
if (!faq) {
throw new ResourceNotFoundError("Faq resource not found!");
}
return faq;
}

ResourceNotFoundError extends HttpError (status 404) — the framework maps it to a 404 response automatically. The controller doesn’t need a branching if (!faq) return response.notFound(...) — it just calls getFaqService(id) and trusts the throw.

Repositories expose protected hooks you can override in your subclass to inject behaviour around CRUD:

class ProductsRepository extends RepositoryManager<Product, ProductListOptions> {
public source = Product;
protected async onCreating(data: any) {
data.slug = slugify(data.name);
}
protected async onCreate(product: Product) {
await searchIndex.add(product);
}
protected async onUpdate(product: Product) {
await searchIndex.update(product);
}
protected async onDelete(id: string | number) {
await searchIndex.remove(id);
}
}

The full list: beforeListing, onList, onCreating, onCreate, onUpdating, onUpdate, onSaving, onSave, onDeleting, onDelete. They’re protected (subclass-only) and async.

For model-level lifecycle (rather than repository-level), use the Cascade model events — see Events and hooks.

  • list() returns { data, pagination }, not just an array. Always destructure. If you find yourself writing result.data.map(...), that’s expected — the wrapper is the contract.
  • listCached caches per filter combination. Two requests with different filters hit two different cache entries. Model writes invalidate all entries for that repository.
  • simpleSelect: true is opt-in. Callers ask for it; the framework doesn’t apply it by default. Use it on heavy list views where you only need a few columns.
  • defaultLimit defaults to 15 at the framework level. Set defaultLimit in defaultOptions to override per repository.
  • Filter rules are not optional. If a caller passes { status: "active" } and there’s no status key in filterBy, the filter is silently dropped — the query is unfiltered. Always wire every filter key you accept.
  • Resources — shaping the repository’s output for the wire.
  • Repositories — deep dive — cursor pagination, custom adapters, chunk(), the full filter operator reference, custom cache drivers.
  • Cache — the cache layer the repository sits on top of.
  • Events and hooks — Cascade model lifecycle events the repository’s cache invalidation hooks into.
  • Cached list recipe — full cached list endpoint, end to end.

Continue to Resources to see how the model becomes the wire response.