Skip to content
Warlock.js v4

Repositories deep dive

The essentials page on repositories introduces the shape and the everyday methods. This page covers the rest — every method on RepositoryManager, every filter operator, both pagination modes, the lifecycle hooks, custom queries past the filter system, the cache layer underneath, and the *Active convenience tier.

Reach for this page when “list and find” stops being enough — when you need cursor pagination on a million-row table, a near(latitude, longitude) filter, a hook that runs every time a record is created, or you’re swapping the cache driver per repository.

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, ProductListFilter> {
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();

Two type parameters on the generic: T (the model) and F (the filter shape). F is what gives repo.list({...}) autocomplete on filter keys.

Every property below is optional except source.

PropertyTypeWhat it does
sourcemodel classUsed by the default adapter resolver. The framework reads it to know what model to query.
filterByFilterRulesMaps filter keys to query operations. The heart of the repository.
defaultOptionsPartial<RepositoryOptions>Applied to every list/all query unless the caller overrides.
simpleSelectColumnsstring[]Column subset returned when callers pass simpleSelect: true.
isActiveColumnstring (default "isActive")The column the *Active variants filter on.
isActiveValueany (default true)The value isActiveColumn must match for *Active queries.
namestringUsed as a prefix in cache keys. Auto-resolved from the model’s table.
isCacheableboolean (default true)Master switch for caching on this repository.
cacheDriverCacheDriverPer-repository cache driver. Falls back to the app’s default.

The exported instance is the singleton — every service in the module imports the same instance.

These are inherited from RepositoryManager. The page in essentials shows the shortest list; this one covers them all.

MethodReturnsNotes
find(id)T | nullBy primary key.
findBy(column, value)T | nullBy any column.
findActive(id)T | nullBy id, filtered by isActiveColumn.
findByActive(col, v)T | nullBy column, filtered by isActiveColumn.
idExists(id)booleanExists-by-id shortcut.
idExistsActive(id)booleanSame, active only.
MethodReturnsNotes
first(options?)T | nullFirst match for the filter.
firstActive(options?)T | nullFirst match, active only.
last(options?)T | nullLast match (orderBy id desc by default).
lastActive(options?)T | nullSame, active only.
firstId(options?)string | numberfirst() then .id.
firstUuid(options?)stringfirst() then .uuid.
firstActiveId(options?)string | numberFirst active, just the id.
firstActiveUuid(options?)stringFirst active, just the uuid.
exists(filter?)booleanExistence check by filter.
existsActive(filter?)booleanSame, active only.

The headline pair plus their variants:

MethodReturnsNotes
list(options?){ data, pagination } (pages or cursor)The default list.
listActive(options?)sameList, active only.
all(options?)T[]Non-paginated. Be careful on big sets.
allActive(options?)T[]Same, active only.
latest(options?){ data, pagination }orderBy: ["id", "desc"].
latestActive(options?)sameLatest, active only.
oldest(options?)sameorderBy: ["id", "asc"].
oldestActive(options?)sameOldest, active only.
MethodReturnsNotes
count(options?)numberCount by filter.
countActive(options?)numberCount, active only.
countCached(options?)numberCached count.
countActiveCached(options?)numberCached count, active only.
MethodReturnsNotes
create(data)TInsert. Fires the model’s creating / created events.
update(id, data)TUpdate by id. Fires updating / updated. Accepts model or id.
delete(id)voidDelete by id. Fires deleting / deleted.
updateMany(filter, data)numberBulk update. Returns affected count.
deleteMany(filter)numberBulk delete. Returns deleted count.
findOrCreate(where, data)TFind or insert if missing.
updateOrCreate(where, data)TTrue upsert.
MethodReturnsNotes
chunk(size, callback, options?)voidProcess in chunks. Return false from the callback to stop.
chunkActive(size, callback, options?)voidSame, active only.

A real chunk pattern — for reindexing or batch jobs:

await productsRepository.chunk(500, async (products, chunkIndex) => {
console.log(`Processing chunk ${chunkIndex}`);
for (const product of products) {
await searchIndex.update(product);
}
});

Every cached variant. The plain list/find/all/first/last versions have a *Cached sibling — pick the cached one for read-heavy endpoints with stable filters.

MethodReturnsNotes
findCached(id) / getCached(id)T | null (cached)The two names are aliases.
getCachedBy(column, value)T | null (cached)Cached version of findBy.
getActiveCached(id)T | undefinedCached, also checks the active column.
firstCached(options?)T | null (cached)Cached first().
firstActiveCached(options?)sameFirst active, cached.
lastCached(options?)T | null (cached)Cached last().
lastActiveCached(options?)sameLast active, cached.
listCached(options?){ data, pagination }Cached page list (page-mode only).
listActiveCached(options?)sameCached list, active only.
allCached(options?)T[] (cached)Cached non-paginated list.
allActiveCached(options?)T[] (cached)Same, active only.
countCached(options?)number (cached)Cached count.
firstCachedId / firstCachedUuidid-or-uuidCached id/uuid helpers.
firstActiveCachedId / Uuidid-or-uuidSame, active.

Cache invalidation is automatic — see the caching section below.

Every list/all/count/first method takes options. The shape is shared:

type RepositoryOptions = {
paginationMode?: "pages" | "cursor";
paginate?: boolean;
page?: number;
limit?: number;
defaultLimit?: number;
select?: string[];
simpleSelect?: true;
deselect?: string[];
orderBy?: "random" | [string, "asc" | "desc"] | { [col]: "asc" | "desc" };
sortBy?: string;
sortDirection?: "asc" | "desc";
cursor?: string | number;
direction?: "next" | "prev";
cursorColumn?: string;
purgeCache?: boolean;
perform?: (query, options) => void;
};

The interesting ones:

FieldPurpose
page1-indexed page number (default 1).
limitItems per page. Defaults via defaultLimit or 15.
defaultLimitFallback when limit isn’t set. Per-repository default.
selectExplicit column allow-list.
simpleSelectWhen true, use simpleSelectColumns from the repository.
deselectExplicit deny-list of columns.
orderByThree forms — see below.
performEscape hatch — custom query callback. Covered in Custom queries.
purgeCacheSet true on a write path if you want to invalidate after.

orderBy has three forms:

// 1. Column → direction map
orderBy: { created_at: "desc", id: "asc" }
// 2. Single-column tuple
orderBy: ["created_at", "desc"]
// 3. Random ordering
orderBy: "random"

And the typed form — TypedRepositoryOptions<F> — is what the public method signatures use. It’s RepositoryOptions & Partial<F>, where F is the filter shape you pass as the second generic. This is what makes repo.list({ organization_id: "..." }) autocomplete-safe.

The default. Returns { data, pagination: { limit, result, page, total, pages } }:

const { data: products, pagination } = await productsRepository.list({
page: 2,
limit: 20,
category_id: "shoes",
});
// pagination:
// {
// limit: 20,
// result: 20, // items in this page
// page: 2,
// total: 84, // total across all pages
// pages: 5,
// }

The full type:

type PaginationResult<T> = {
data: T[];
pagination: {
limit: number;
result: number;
page: number;
total: number;
pages: number;
};
};

This is the default for list(), listActive(), listCached(), latest(), oldest().

For tables where counting total would be expensive (millions of rows) or for “load more” infinite scroll UIs, switch to cursor mode by passing paginationMode: "cursor":

const { data: products, pagination } = await productsRepository.list({
paginationMode: "cursor",
limit: 20,
cursor: lastSeenId,
direction: "next",
cursorColumn: "id",
});
// pagination:
// {
// limit: 20,
// result: 20,
// hasMore: true,
// nextCursor: 1234,
// prevCursor: 1215,
// }

The full type:

type CursorPaginationResult<T> = {
data: T[];
pagination: {
limit: number;
result: number;
hasMore: boolean;
nextCursor?: string | number;
prevCursor?: string | number;
};
};

The cursor mode owns the sort order for cursorColumn — passing orderBy on the same column is silently overridden and warns. Pass orderBy for a secondary sort if you want one.

The list() method has overloads — when you pass paginationMode: "cursor", TypeScript narrows the return to CursorPaginationResult<T>. Without it, you get PaginationResult<T>.

The cached listCached(...) is page-mode only. Cursor pagination skips the cache.

filterBy is the heart of the repository. Each rule maps a filter key to a query operation. The full type:

type FilterRule =
| FilterOperator // "=" → WHERE key = value
| FilterFunction // (value, query, ctx) => void
| [FilterOperator] // ["="] → same as "="
| [FilterOperator, string] // ["in", "id"] → rename
| [FilterOperator, string[]]; // ["like", ["name", "description"]] → OR across columns
public filterBy: FilterRules = {
// 1. Plain operator — column name matches the key
id: "=",
// 2. Operator + rename — incoming "ids" applies to column "id"
ids: ["in", "id"],
// 3. Operator + multi-column — OR across columns
search: ["like", ["name", "description"]],
// 4. Custom function — fully bespoke logic
near: (value, query) => {
query.whereRaw("ST_Distance(location, ?) < ?", [value.point, value.radius]);
},
};

Every operator the Cascade adapter supports. Anything else falls through to WhereOperator (the SQL operators), which you can also use directly.

OperatorWhat it doesFilter input
"="WHERE col = valuescalar
"!=" / "<>"WHERE col != valuescalar
">" / ">="WHERE col > value / >=scalar
"<" / "<="WHERE col < value / <=scalar
"like"WHERE col LIKE valuestring with % or plain (framework adds)
"not like"WHERE col NOT LIKE valuestring
"in"WHERE col IN (...)array (single-value auto-wraps)
"not in"WHERE col NOT IN (...)array
"between"WHERE col BETWEEN a AND b[a, b]
"not between"WHERE col NOT BETWEEN a AND b[a, b]

These coerce the incoming filter value before applying the comparison — useful when the query string came from an HTTP request and everything is a string by default.

OperatorBehaviour
"bool" / "boolean"Coerce value via truthy rules, then WHERE col = value.
"int" / "integer"parseInt(value), then WHERE col = value.
"!int"parseInt(value), then WHERE col != value.
"int>" / "int>=" / "int<" / "int<="parseInt, then WHERE col <op> value.
"inInt"parseInt on each item, then WHERE col IN (...).
"number"Number(value), then WHERE col = value.
"inNumber"Number on each item, then WHERE col IN (...).
"float" / "double"parseFloat(value), then WHERE col = value.
"inFloat"parseFloat per item, then WHERE col IN (...).
OperatorBehaviour
"null"WHERE col IS NULL.
"notNull" / "!null"WHERE col IS NOT NULL.

These respect dateFormat: "DD-MM-YYYY" and dateTimeFormat: "DD-MM-YYYY HH:mm:ss" (the framework defaults).

OperatorBehaviour
"date"Parse value as date, then WHERE col = date.
"date>" / "date>=" / "date<" / "date<="Parse + comparison.
"dateBetween"Value is [from, to]. WHERE col BETWEEN.
"inDate"Array of dates. WHERE col IN (...).
"dateTime"Same shape as "date" but uses dateTimeFormat.
"dateTime>" / >= / < / <=DateTime comparisons.
"dateTimeBetween"DateTime between.
"inDateTime"DateTime IN.

These reach into Cascade’s relation system from the filter layer.

OperatorBehaviour
"with"Eager-load the named relation when the filter value is truthy.
"joinWith"Eager-load via SQL JOIN (forces an INNER JOIN — implicitly filters).
"scope"Apply a model scope (e.g. Model.scope("published")) when value is truthy.
"similarTo"Vector similarity search — column similarTo embeddingArray.

A real example from the reference codebase — joining contacts onto chat queries:

public filterBy: FilterRules = {
organization_id: "=",
withContact: ["joinWith", "contact"], // filter input is truthy → JOIN contact
aiAgent: ["joinWith", "aiAgent"],
isEscalated: ["bool", "is_escalated"],
hasNoStaff: (_value, query) => query.whereNull("staff_id"),
};

A caller passing { withContact: true } gets the contact relation joined into the query. Passing { hasNoStaff: true } runs the custom function — there’s no point where the framework guesses; you said what you wanted.

If a caller passes a filter key that isn’t in filterBy, it’s silently dropped. The query runs unfiltered for that key. This is by design — your repository declares its supported filters and the framework refuses to guess. The flip side: if you want a filter, you must wire it in filterBy. Forgetting is the most common repository bug.

A defensive pattern: always declare every filter key your F type advertises. TypeScript shows you F; filterBy must cover it.

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 most of the time so the boring cases stay one-line.

A reasonable choice for most catalog-style repositories: orderBy: { id: "desc" } and a defaultLimit higher or lower than the framework default of 15, depending on the entity.

Sometimes the filter system isn’t enough. Three escape hatches, in order of escalating ceremony:

The simplest one — pass a function that mutates the query builder:

const result = await productsRepository.list({
category_id: "shoes",
perform: (query) => {
query.where("inventory_count", ">", 0);
query.orderBy("popularity_score", "desc");
},
});

Useful for one-off “this controller needs an extra clause” cases without bloating filterBy.

For reusable cases that don’t fit the filter shape:

class ContactsRepository extends RepositoryManager<Contact, ContactsRepositoryFilter> {
public source = Contact;
// ...filterBy / defaultOptions...
public async findByPhone(organizationId: string, phone: string): Promise<Contact | null> {
return this.first({ organization_id: organizationId, phone });
}
public async findByEmail(organizationId: string, email: string): Promise<Contact | null> {
return this.first({ organization_id: organizationId, email });
}
}

This is from the reference codebase. The method wraps first() with a more meaningful name and a tighter type. Same caching story (none here — these aren’t *Cached). Same lifecycle hooks.

When you need joins, raw SQL, complex WHERE trees:

class OrdersRepository extends RepositoryManager<Order, OrderFilter> {
public source = Order;
public async listWithCustomerSpend(orgId: string, minSpend: number) {
const query = this.newQuery();
query
.where("organization_id", orgId)
.joinWith("customer")
.whereRaw("customer.total_spend >= ?", [minSpend])
.orderBy("created_at", "desc");
return query.paginate(1, 50);
}
}

this.newQuery() returns a fresh query builder bound to the repository’s adapter. The paginate(...) call returns the same PaginationResult<T> shape, so callers see no difference.

The three write methods 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);

update() also accepts a model instance directly — handy when you already have it loaded:

const product = await productsRepository.find(id);
if (!product) throw new ResourceNotFoundError("Product");
await productsRepository.update(product, { price: 24.99 });

Bulk operations skip the model lifecycle (faster, less safe):

const archivedCount = await productsRepository.updateMany(
{ status: "draft" },
{ status: "archived" },
);
const purgedCount = await productsRepository.deleteMany({ status: "archived" });

All single-record writes fire the Cascade model events (creatingcreated, updatingupdated, deletingdeleted). The repository’s cache invalidation hooks into these — every write clears the repository’s cache namespace.

You can also create() directly on the model (Product.create(...)) when you’re inside a service that has the model imported. Both paths fire the same events; pick whichever reads cleaner.

Lifecycle hooks — onCreating, onCreate, …

Section titled “Lifecycle hooks — onCreating, onCreate, …”

The repository class exposes protected hooks. Override them in your subclass to inject behaviour around CRUD:

class ProductsRepository extends RepositoryManager<Product, ProductListFilter> {
public source = Product;
protected async onCreating(data: any) {
data.slug = slugify(data.name);
}
protected async onCreate(product: Product, data: any) {
await searchIndex.add(product);
}
protected async onUpdating(id: string | number, data: any) {
if (data.name) {
data.slug = slugify(data.name);
}
}
protected async onUpdate(product: Product, data: any) {
await searchIndex.update(product);
}
protected async onSaving(data: any, mode: "create" | "update" | "patch") {
data.touchedAt = new Date();
}
protected async onSave(product: Product, data: any, mode: "create" | "update" | "patch") {
// Fires for both create and update.
}
protected async onDeleting(id: string | number) {
await this.unlinkExternalReferences(id);
}
protected async onDelete(id: string | number) {
await searchIndex.remove(id);
}
protected async beforeListing(options: any) {
// Inspect or mutate options before list() runs.
}
protected async onList(result: PaginationResult<Product>, options: any) {
// Inspect or augment results after list() returns.
}
}

The full list:

HookWhen it firesSignature
beforeListingBefore list() runs the query(options) => void
onListAfter list() returns(result, options) => void
onCreatingBefore create() calls the adapter(data) => void
onCreateAfter create() returns(record, data) => void
onUpdatingBefore update() calls the adapter(id, data) => void
onUpdateAfter update() returns(record, data) => void
onSavingBefore save (both create and update)(data, mode) => void
onSaveAfter save (both create and update)(record, data, mode) => void
onDeletingBefore delete() calls the adapter(id) => void
onDeleteAfter delete() returns(id) => void

All hooks are async and protected — you only see them inside your subclass.

For model-level lifecycle (fetching, validating, restoring, …) use Cascade’s model events instead — see Events and hooks. Repository hooks are about repository-level interception; model events fire even when you Product.create(...) directly on the model.

isCacheable is the master switch. When it’s true (the default), every *Cached method reads from / writes to the configured cache driver.

Cache keys are namespaced per repository:

repositories.<name>.list.<JSON-of-options>
repositories.<name>.id.<id>
repositories.<name>.count.<JSON-of-options>
repositories.<name>.all.<JSON-of-options>

<name> resolves from this.name if set, otherwise from this.adapter.resolveRepositoryName() — for Cascade, that’s the model’s table.

Invalidation is automatic: when the model fires created, updated, or deleted, the repository’s clearCache() runs, which removes the entire namespace:

public registerEvents() {
this.eventsCallbacks.push(
...this.adapter.registerEvents((source: any) => {
this.clearCache();
}),
);
}

Writes on this repository or any other code path that fires the model events trigger invalidation. That’s why you should let the repository own writes for cached models — going around via raw queries means stale cache.

For finer control, you can invalidate manually:

await productsRepository.clearCache(); // wipe the whole namespace
await productsRepository.clearModelCache(prod); // wipe a single id

The cache driver is configurable. The default is the framework’s shared cache from @warlock.js/cache. To use a different driver for one repository — say, a Redis-backed one for hot data and memory for cool — call setCacheDriver:

import { redisCache } from "app/shared/cache/redis";
productsRepository.setCacheDriver(redisCache);

Or set it directly on the class:

class ProductsRepository extends RepositoryManager<Product, ProductListFilter> {
public source = Product;
protected cacheDriver = redisCache;
}

Per-repository:

class ProductsRepository extends RepositoryManager<Product, ProductListFilter> {
public source = Product;
protected isCacheable = false;
}

Now every *Cached method falls through to its uncached sibling — same return shape, no read or write to the cache.

App-wide: set config.get("repository.isCacheable") to false. The per-repository value still wins.

The *Active variants are convenience methods for the common “soft-active” pattern — a row has an isActive column, and most queries should filter on it.

By default, isActiveColumn = "isActive" and isActiveValue = true. Every *Active method adds { [isActiveColumn]: isActiveValue } to the filter:

const products = await productsRepository.listActive({
category_id: "shoes",
});
// Equivalent to:
const products = await productsRepository.list({
category_id: "shoes",
isActive: true,
});

You can change the column or value per repository. The chats repository from the reference codebase uses status = "active":

class ChatRepository extends RepositoryManager<Chat, ChatsFilter> {
public source = Chat;
public isActiveColumn = "status";
public isActiveValue = "active";
}

Now listActive() filters by status = "active" instead of isActive = true. Same shape, same convenience.

Every read method has an active sibling — findActive, listActive, allActive, firstActive, lastActive, firstActiveCached, countActive, and so on. Pick the active variant when you want the soft-active filter; pick the plain one when you don’t.

config.get("repository") exposes the defaults for every repository:

src/config/repository.ts
import { defineConfig } from "@warlock.js/core";
export default defineConfig({
repository: {
isCacheable: true,
cacheDriver: customCache,
isActiveColumn: "isActive",
isActiveValue: true,
adapterResolver: (repo) => new CascadeAdapter(repo.source),
defaultOptions: {
defaultLimit: 25,
},
},
});

The shape:

type RepositoryConfigurations = {
cacheDriver?: CacheDriver;
adapterResolver?: (repo) => RepositoryAdapterContract;
defaultOptions?: Partial<RepositoryOptions>;
isActiveColumn?: string;
isActiveValue?: any;
};

adapterResolver is how you’d swap to a non-Cascade ORM at runtime — return a different RepositoryAdapterContract implementation per repository or globally. Out of the box, the framework ships only the Cascade adapter; building a Prisma or Drizzle adapter means implementing the RepositoryAdapterContract interface.

The chats repository from the reference codebase — including custom methods, a joinWith relation filter, and a custom function filter:

src/app/chats/repositories/chats.repository.ts
import { type FilterRules, type RepositoryOptions, RepositoryManager } from "@warlock.js/core";
import { Chat } from "../models/chat";
import { ChatHandler, ChatStatus } from "../utils/chat-types";
type ChatsFilter = {
organization_id?: string;
unit_id?: string;
unitId?: string;
status?: string;
aiAgent?: true;
withContact?: true;
id?: string;
handler?: string;
staff_id?: string;
hasNoStaff?: true;
isEscalated?: boolean;
};
class ChatRepository extends RepositoryManager<Chat, ChatsFilter> {
public source = Chat;
public simpleSelectColumns: string[] = ["id", "status", "started_at"];
public isActiveColumn = "status";
public isActiveValue = "active";
public filterBy: FilterRules = {
organization_id: "=",
id: "=",
unit_id: "=",
unitId: ["=", "unit_id"],
status: "=",
handler: "=",
staff_id: "=",
aiAgent: ["joinWith", "aiAgent"],
withContact: ["joinWith", "contact"],
isEscalated: ["bool", "is_escalated"],
hasNoStaff: (_value, query) => query.whereNull("staff_id"),
};
public defaultOptions: RepositoryOptions = {
orderBy: {
started_at: "desc",
},
};
public async listUnclaimedHandoffs(organizationId: string) {
return this.list({
organization_id: organizationId,
hasNoStaff: true,
isEscalated: true,
});
}
public async listActiveAIChats(organizationId: string) {
return this.list({
organization_id: organizationId,
status: ChatStatus.ACTIVE,
handler: ChatHandler.AI,
});
}
public async listActiveStaffChats(organizationId: string, staffId: string) {
return this.list({
organization_id: organizationId,
status: ChatStatus.ACTIVE,
handler: ChatHandler.STAFF,
staff_id: staffId,
});
}
}
export const chatsRepository = new ChatRepository();

What’s interesting here:

  • isActiveColumn is "status" and isActiveValue is "active". The active-record convention is per-repository.
  • aiAgent and withContact are joinWith filters. When the caller passes { aiAgent: true }, the framework JOINs the aiAgent relation onto the query.
  • hasNoStaff is a custom function. It needs WHERE staff_id IS NULL, which doesn’t fit any operator — the function form is the right tool.
  • Three custom methods wrap the common cases in named patterns. The service layer calls these instead of remembering the right combination of filter keys.
  • list() returns { data, pagination }, not just an array. Always destructure.
  • listCached caches per filter combination. Two requests with different filter values hit two different cache entries. Model writes invalidate everything for the repository.
  • simpleSelect: true is opt-in. Callers ask for it. Repositories never apply it by default.
  • defaultLimit is 15 at the framework level. Override in defaultOptions to change per repository.
  • Filter rules are not optional. A caller passing { status: "active" } with no status key in filterBy gets an unfiltered query and a silent drop. Wire every filter key you accept.
  • Cursor pagination owns the sort order for its cursorColumn. Passing orderBy on the same column is silently overridden with a console warning.
  • updateMany / deleteMany skip the model lifecycle. Fast, but no creating / updating / deleting events fire — and therefore no cache invalidation. Call clearCache() manually after a bulk write, or accept stale cache until next mutation.
  • Bulk paginate methods (listCached) only support page mode. Cursor pagination skips the cache entirely.
  • The repository singleton pattern is non-negotiable if you want the cache to be shared. Two new ProductsRepository() instances have separate cache misses; export one and import it everywhere.