zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Schema and ORM

Query CachingSupported

Repository result caching is off by default. A read opts in at the call site, where its staleness tolerance is visible:

const user = await userRepo.findById(userId, {
  cache: {
    ttlMs: 5_000,
    tags: [`user:${userId}`],
  },
});

findById, findOne, find, findAll and list accept the same cache option. Omitting it performs no key construction and no store call. Passing cache: false bypasses an existing store in both directions: it neither reads nor fills the cache.

Invalidation is the API#

Every cached read receives an automatic table:<table> tag. A successful write through that repository invalidates the table tag after the driver operation completes:

await userRepo.findById(userId, {
  cache: { ttlMs: 30_000 },
});

await userRepo.update(userId, { email: nextEmail });
// The next cached users read misses because table:users was invalidated.

Table invalidation is coarse on purpose: changing one user invalidates every cached users query. The repository does not attempt to decide which cached WHERE clauses the changed row satisfies.

Use caller tags when another table or a narrower application concept also depends on the write. Repositories participating in a cross-repository tag must be constructed with the same CacheStore:

const tag = `account-summary:${accountId}`;

const summary = await summaryRepo.findOne({ accountId }, { cache: { ttlMs: 30_000, tags: [tag] } });

await userRepo.update(userId, { email: nextEmail }, { invalidateTags: [tag] });

create, upsert, update, updateMany, increment, delete, deleteMany, hardDelete, and restore accept invalidateTags in their write options. The repository invalidates its table tag plus those caller tags. It does not infer cross-table dependencies.

What invalidation cannot see#

The guarantee stops at the repository/store boundary:

Choose ttlMs as the maximum stale interval the caller can tolerate under those conditions. Do not cache a result that must always reflect an out-of-band write.

Default and pluggable stores#

The first opted-in read without a configured store lazily creates a process-local memoryStore() for that repository. It is TTL-aware, least-recently-used and bounded to 1,000 entries by default:

import { memoryStore } from '@zmdb/orm';

const store = memoryStore({ maxEntries: 5_000 });
const users = new UserRepository(driver, 'postgres', { cacheStore: store });

A shared backend implements three operations:

export interface CacheStore {
  get(key: string): Promise<unknown | undefined>;
  set(key: string, value: unknown, ttlMs: number, tags: readonly string[]): Promise<void>;
  invalidateTags(tags: readonly string[]): Promise<void>;
}

No store is global or ambient. Pass it in the repository constructor or through defineRepository(..., { cacheStore }).

Store failures degrade to the database path. A failed get performs the read; failed set and invalidateTags calls do not replace a successful database result or write with a cache error. The first failure is reported once per repository instance.

Keys, deploys and revalidation#

Keys are deterministic readable strings containing the dialect, schema IR fingerprint, table, compiled SQL and type-tagged parameters. A numeric 1 and a string '1' are different keys; object keys are sorted; binary view types remain distinct.

The schema fingerprint makes a declaration change miss an older value in a shared store. Cache hits are not revalidated against the database: doing that round trip would remove the benefit of the cache. TTL, tags and cache: false are the explicit freshness controls.

Results are returned as fresh shallow row copies. Treat them as immutable: reassigning a top-level property does not change a later hit, but nested JSON objects are not deep-cloned and must not be mutated.

Why this is not an identity map#

PropertyResult cacheIdentity map
Entry pointExplicit cache optionEvery entity read
FreshnessTTL, table tags and caller tagsSession coherence
Object identityFresh shallow copySame object reference
WritesExplicit invalidation, no write-throughTracks objects for flush/coherence
DefaultOffFundamental ORM behavior

The cache stores query results, not live entities. It never observes property mutation, infers writes or makes ordinary uncached reads consult prior objects. The request-scoped DataLoader is narrower still: its lifetime is one explicit request context.

---

See also: Query Performance · Writing a Driver · DataLoaders · Why fetched rows are inert